From fc1e589613a14750c2ca8c34838a71c758544c8d Mon Sep 17 00:00:00 2001 From: Iain Dickson Date: Mon, 27 Jul 2026 02:17:29 +1000 Subject: [PATCH 001/328] fix(self-host): preserve native MCP elicitation mode (#1459) --- .changeset/fuzzy-dragons-elicit.md | 5 ++ .../mcp/src/in-memory-session-store.test.ts | 54 +++++++++++++++++++ .../hosts/mcp/src/in-memory-session-store.ts | 3 +- 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 .changeset/fuzzy-dragons-elicit.md create mode 100644 packages/hosts/mcp/src/in-memory-session-store.test.ts diff --git a/.changeset/fuzzy-dragons-elicit.md b/.changeset/fuzzy-dragons-elicit.md new file mode 100644 index 0000000000..87d93e0bf2 --- /dev/null +++ b/.changeset/fuzzy-dragons-elicit.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Preserve `elicitation_mode=native` when creating self-hosted MCP sessions. diff --git a/packages/hosts/mcp/src/in-memory-session-store.test.ts b/packages/hosts/mcp/src/in-memory-session-store.test.ts new file mode 100644 index 0000000000..8d87f56970 --- /dev/null +++ b/packages/hosts/mcp/src/in-memory-session-store.test.ts @@ -0,0 +1,54 @@ +import { expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + makeInMemoryMcpSessionStore, + McpEngineBuildError, + type McpBuildServerOptions, +} from "./in-memory-session-store"; +import { defaultMcpResource, type Principal } from "./seams"; + +const TEST_PRINCIPAL: Principal = { + accountId: "acct_test", + organizationId: "org_test", + organizationName: "Test Org", + email: "test@example.com", + name: "Test", + avatarUrl: null, + roles: ["user"], +}; + +it("preserves native elicitation mode when creating an in-memory MCP session", async () => { + let buildOptions: McpBuildServerOptions | undefined; + const sessions = makeInMemoryMcpSessionStore((_principal, options) => { + buildOptions = options; + return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" })); + }); + + const result = await Effect.runPromise( + sessions.store.dispatch({ + request: new Request("https://executor.test/mcp?elicitation_mode=native", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: { elicitation: { form: {} } }, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + }), + }), + principal: TEST_PRINCIPAL, + resource: defaultMcpResource, + sessionId: null, + method: "POST", + }), + ); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(500); + expect(buildOptions?.elicitationMode).toEqual({ mode: "native" }); +}); diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index e2d233e8cd..4e5e535224 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -218,7 +218,8 @@ export const makeInMemoryMcpSessionStore = ( request: Request, sessionId: () => string | null, ): McpBuildServerOptions => { - if (readElicitationMode(request) !== "browser") return { elicitationMode: { mode: "model" } }; + const mode = readElicitationMode(request); + if (mode !== "browser") return { elicitationMode: { mode } }; return { elicitationMode: { mode: "browser", From 5a706756c66e53c9a929e9a8c30e57166b8d121b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:40:05 -0700 Subject: [PATCH 002/328] Fix org OAuth connection token partitioning in encrypted-secrets (#1462) * Add failing e2e repro for org OAuth token partitioning (#1453) * Partition encrypted-secrets rows by the owner embedded in the item id * Share the item-id owner grammar and harden the repartition migration --- .changeset/encrypted-secrets-org-partition.md | 7 + .../src/db/data-migrations.test.ts | 3 + .../host-cloudflare/src/db/data-migrations.ts | 4 + apps/host-selfhost/src/db/data-migrations.ts | 4 + .../mcp-browser-approval-ownership.test.ts | 49 +--- ...uth-org-connection-cross-principal.test.ts | 275 ++++++++++++++++++ e2e/targets/selfhost.ts | 49 ++++ packages/core/sdk/src/index.ts | 8 + packages/core/sdk/src/provider-item-owner.ts | 47 +++ .../encrypted-secrets/src/index.test.ts | 55 +++- .../plugins/encrypted-secrets/src/index.ts | 33 ++- .../src/repartition-migration.test.ts | 208 +++++++++++++ .../src/repartition-migration.ts | 123 ++++++++ .../workos-vault/src/sdk/secret-store.ts | 37 +-- 14 files changed, 809 insertions(+), 93 deletions(-) create mode 100644 .changeset/encrypted-secrets-org-partition.md create mode 100644 e2e/selfhost/oauth-org-connection-cross-principal.test.ts create mode 100644 packages/core/sdk/src/provider-item-owner.ts create mode 100644 packages/plugins/encrypted-secrets/src/repartition-migration.test.ts create mode 100644 packages/plugins/encrypted-secrets/src/repartition-migration.ts diff --git a/.changeset/encrypted-secrets-org-partition.md b/.changeset/encrypted-secrets-org-partition.md new file mode 100644 index 0000000000..ca4ef39a9d --- /dev/null +++ b/.changeset/encrypted-secrets-org-partition.md @@ -0,0 +1,7 @@ +--- +"executor": patch +--- + +**Fix: org OAuth connections on self-host worked only for whoever ran the consent** + +The encrypted-secrets credential provider (the writable provider on the self-hosted and Cloudflare hosts) filed token rows under the _acting user's_ private partition instead of the credential's own owner. An org-owned OAuth connection whose consent completed in one member's browser session therefore resolved only for that member — every other principal failed with `oauth_connection_missing`, while the UI showed the connection healthy. The provider now partitions by the owner embedded in the item id (`oauth:org:…` → org-shared), matching the WorkOS Vault provider, and a boot-time data migration re-files rows already written wrong. The encrypted value itself was never affected. diff --git a/apps/host-cloudflare/src/db/data-migrations.test.ts b/apps/host-cloudflare/src/db/data-migrations.test.ts index 02dc9be278..254c023776 100644 --- a/apps/host-cloudflare/src/db/data-migrations.test.ts +++ b/apps/host-cloudflare/src/db/data-migrations.test.ts @@ -241,6 +241,7 @@ describe("runCloudflareDataMigrations", () => { "2026-06-20-google-openapi-ownership", "2026-07-08-provider-service-split", "2026-07-09-openapi-ndjson-output-arrays", + "2026-07-27-encrypted-secrets-owner-repartition", ]); expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); @@ -285,6 +286,7 @@ describe("runCloudflareDataMigrations", () => { "2026-06-20-google-openapi-ownership", "2026-07-08-provider-service-split", "2026-07-09-openapi-ndjson-output-arrays", + "2026-07-27-encrypted-secrets-owner-repartition", ]); expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); @@ -336,6 +338,7 @@ describe("runCloudflareDataMigrations", () => { "2026-06-20-google-openapi-ownership", "2026-07-08-provider-service-split", "2026-07-09-openapi-ndjson-output-arrays", + "2026-07-27-encrypted-secrets-owner-repartition", ]); expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); diff --git a/apps/host-cloudflare/src/db/data-migrations.ts b/apps/host-cloudflare/src/db/data-migrations.ts index 85f48b3713..4e511505da 100644 --- a/apps/host-cloudflare/src/db/data-migrations.ts +++ b/apps/host-cloudflare/src/db/data-migrations.ts @@ -9,6 +9,7 @@ import { } from "@executor-js/sdk"; import { openApiNdjsonOutputDataMigration } from "@executor-js/plugin-openapi"; import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google"; +import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-encrypted-secrets"; import { providerServiceSplitDataMigration, @@ -246,6 +247,9 @@ const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly Sqlite // Stale-mark connections whose operations return NDJSON so their tool rows // rebuild with array-wrapped output schemas (mirrors cloud's drizzle 0010). openApiNdjsonOutputDataMigration, + // Re-file credential rows the pre-fix provider stored under the acting + // caller's partition instead of the owner embedded in the item id (#1453). + encryptedSecretsRepartitionDataMigration, ]; export const runCloudflareDataMigrations = ( diff --git a/apps/host-selfhost/src/db/data-migrations.ts b/apps/host-selfhost/src/db/data-migrations.ts index 295f77d5b4..99d814f494 100644 --- a/apps/host-selfhost/src/db/data-migrations.ts +++ b/apps/host-selfhost/src/db/data-migrations.ts @@ -16,6 +16,7 @@ import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graph import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google"; import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split"; +import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-encrypted-secrets"; import { authConfigTransforms } from "./auth-config-migration"; export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ @@ -37,4 +38,7 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ // Stale-mark connections whose operations return NDJSON so their tool rows // rebuild with array-wrapped output schemas (mirrors cloud's drizzle 0010). openApiNdjsonOutputDataMigration, + // Re-file credential rows the pre-fix provider stored under the acting + // caller's partition instead of the owner embedded in the item id (#1453). + encryptedSecretsRepartitionDataMigration, ]; diff --git a/e2e/selfhost/mcp-browser-approval-ownership.test.ts b/e2e/selfhost/mcp-browser-approval-ownership.test.ts index 8924ff6e5a..3b532de01c 100644 --- a/e2e/selfhost/mcp-browser-approval-ownership.test.ts +++ b/e2e/selfhost/mcp-browser-approval-ownership.test.ts @@ -1,8 +1,6 @@ // Selfhost-only: the browser-approval HTTP endpoints are session-scoped. A // signed-in user who does not own the MCP session must not be able to read the // paused execution or record the human decision for it. -import { randomBytes } from "node:crypto"; - import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { composePluginApi } from "@executor-js/api/server"; @@ -10,8 +8,7 @@ import { composePluginApi } from "@executor-js/api/server"; import { scenario } from "../src/scenario"; import { Api, Mcp, Target } from "../src/services"; import { parseBrowserApproval } from "../src/surfaces/mcp"; -import type { Identity } from "../src/target"; -import { signInSession } from "../targets/selfhost"; +import { createInvitedIdentity } from "../targets/selfhost"; const coreApi = composePluginApi([] as const); @@ -21,46 +18,6 @@ const result = await tools.executor.coreTools.policies.list({}); return JSON.stringify(result); `; -const createInvitedIdentity = async (baseUrl: string, admin: Identity): Promise => { - const cookie = admin.headers?.cookie; - expect(typeof cookie, "bootstrap admin has a Better Auth session cookie").toBe("string"); - - const invite = await fetch(new URL("/api/admin/invites", baseUrl), { - method: "POST", - headers: { - "content-type": "application/json", - cookie: cookie!, - origin: new URL(baseUrl).origin, - }, - body: JSON.stringify({ role: "member" }), - }); - expect(invite.status, `admin invite create response: ${await invite.clone().text()}`).toBe(200); - const inviteBody = (await invite.json()) as { readonly code?: string }; - expect(typeof inviteBody.code, "invite response includes a redeemable code").toBe("string"); - - const email = `approval-cross-user-${randomBytes(5).toString("hex")}@e2e.test`; - const password = "approval-cross-user-password-123"; - const signup = await fetch(new URL("/api/auth/sign-up/email", baseUrl), { - method: "POST", - headers: { "content-type": "application/json", origin: new URL(baseUrl).origin }, - body: JSON.stringify({ - email, - password, - name: email, - inviteCode: inviteBody.code, - }), - }); - expect(signup.status, `invited signup response: ${await signup.clone().text()}`).toBe(200); - - const session = await signInSession(baseUrl, { email, password }); - return { - label: email, - credentials: { email, password }, - headers: { cookie: session.cookieHeader }, - cookies: session.cookies, - }; -}; - const approvalEndpoint = (baseUrl: string, sessionId: string, executionId: string): URL => new URL( `/api/mcp-sessions/${encodeURIComponent(sessionId)}/executions/${encodeURIComponent( @@ -77,7 +34,9 @@ scenario( const api = yield* Api; const mcp = yield* Mcp; const owner = yield* target.newIdentity(); - const other = yield* Effect.promise(() => createInvitedIdentity(target.baseUrl, owner)); + const other = yield* Effect.promise(() => + createInvitedIdentity(target.baseUrl, owner, { emailPrefix: "approval-cross-user" }), + ); const client = yield* api.client(coreApi, owner); const policy = yield* client.policies.create({ diff --git a/e2e/selfhost/oauth-org-connection-cross-principal.test.ts b/e2e/selfhost/oauth-org-connection-cross-principal.test.ts new file mode 100644 index 0000000000..506c980ab5 --- /dev/null +++ b/e2e/selfhost/oauth-org-connection-cross-principal.test.ts @@ -0,0 +1,275 @@ +// Selfhost-only: an ORG-owned OAuth connection must work for every member of +// the org, not just whoever's browser session completed the consent. +// +// Issue #1453: the encrypted-secrets provider (the writable provider on +// self-host and the Cloudflare host) filed token rows under the ACTING +// caller's partition — so completing an org connection's consent in a user +// session landed the tokens at owner='user', subject=, while the +// connection row itself said org. The consenting user's own calls still read +// the tokens (user rows shadow org rows), so the connection looked healthy — +// but every other principal resolved no value and failed with +// `oauth_connection_missing`. The same mis-filing was fixed for the WorkOS +// Vault provider in #950. +// +// The journey: the bootstrap admin registers an OAuth-protected integration, +// completes the org-owned authorization-code flow (a real test AS on +// 127.0.0.1), and proves the tool call carries the minted token upstream. A +// second, invited member of the same org then invokes the same tool — the +// guarantee under test is that the same org connection resolves the same +// token for them too. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; +import { createInvitedIdentity } from "../targets/selfhost"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}${randomBytes(4).toString("hex")}`; + +/** Upstream on 127.0.0.1: `GET /me` echoes the authorization header it + * received, so the scenario can assert a real token reached it — not merely + * that a request was made. */ +const serveUpstream = () => + Effect.acquireRelease( + Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => { + const server = createServer((request, response) => { + if (request.method === "GET" && (request.url ?? "").startsWith("/me")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ authorization: request.headers.authorization ?? null })); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +const specFor = ( + baseUrl: string, + oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string }, +): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Me API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/me": { + get: { + operationId: "getMe", + summary: "The caller", + security: [{ oauth: ["read"] }], + responses: { "200": { description: "the caller" } }, + }, + }, + }, + components: { + securitySchemes: { + oauth: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: { read: "Read" }, + }, + }, + }, + }, + }, + }); + +/** Complete the test AS's consent headlessly and return the authorization + * code from the callback redirect. */ +const consentCode = (authorizationUrl: string) => + Effect.promise(async () => { + const authorize = await fetch(authorizationUrl, { redirect: "manual" }); + const loginUrl = authorize.headers.get("location"); + if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`); + const login = await fetch(loginUrl, { + method: "POST", + headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}` }, + redirect: "manual", + }); + const callbackUrl = login.headers.get("location"); + if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`); + const code = new URL(callbackUrl).searchParams.get("code"); + if (!code) throw new Error("callback carried no authorization code"); + return code; + }); + +type ToolEnvelope = { + readonly ok: boolean; + readonly data?: { readonly authorization?: string | null }; + readonly error?: { readonly code?: string; readonly message?: string }; +}; + +/** Navigate the sandbox's `tools` object to the address and invoke it. */ +const invokeByAddressCode = (address: string) => ` +const segments = ${JSON.stringify(address)}.split(".").slice(1); +let node = tools; +for (const segment of segments) node = node[segment]; +const result = await node({}); +return result; +`; + +scenario( + "OAuth · an org-owned connection consented in one member's session resolves for another member", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const admin = yield* target.newIdentity(); + const adminClient = yield* makeClient(api, admin); + const upstream = yield* serveUpstream(); + const oauth = yield* serveOAuthTestServer({ scopes: ["read"] }); + + const integration = IntegrationSlug.make(unique("orgoauth")); + const clientSlug = OAuthClientSlug.make(unique("orgoauthc")); + const connectionName = ConnectionName.make("main"); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* adminClient.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: specFor(upstream.url, oauth) }, + slug: integration, + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "oauth", + kind: "oauth2", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: ["read"], + }, + ], + }, + }); + yield* adminClient.oauth.createClient({ + payload: { + owner: "org", + slug: clientSlug, + grant: "authorization_code", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + clientId: "test-client", + clientSecret: "test-secret", + }, + }); + + // The admin's browser session completes the ORG-owned consent. + const started = yield* adminClient.oauth.start({ + payload: { + client: clientSlug, + clientOwner: "org", + owner: "org", + name: connectionName, + integration, + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(started.status, "oauth.start redirects to the authorization server").toBe( + "redirect", + ); + if (started.status !== "redirect") return yield* Effect.die("no redirect"); + const code = yield* consentCode(started.authorizationUrl); + const connection = yield* adminClient.oauth.complete({ + payload: { state: started.state, code }, + }); + expect(connection, "the minted connection is org-owned").toMatchObject({ + owner: "org", + name: connectionName, + integration, + }); + + const tools = yield* adminClient.tools.list({ query: {} }); + const address = tools + .filter((tool) => String(tool.integration) === String(integration)) + .map((tool) => String(tool.address)) + .find((addr) => addr.endsWith("getMe")); + expect(address, "the integration's tool is in the catalog").toBeDefined(); + + const invoke = (client: typeof adminClient, who: string) => + Effect.gen(function* () { + const execution = yield* client.executions.execute({ + payload: { code: invokeByAddressCode(address!) }, + }); + expect(execution.status, `${who}: the execution completes`).toBe("completed"); + if (execution.status !== "completed") return yield* Effect.die("not completed"); + return (execution.structured as { result?: ToolEnvelope }).result; + }); + + // Sanity: the consenting member's call carries a real bearer to the + // upstream — this passing is exactly why the bug is invisible to + // whoever set the connection up. + const adminResult = yield* invoke(adminClient, "consenting member"); + expect( + adminResult?.ok, + `the consenting member's tool call resolves the org connection (got: ${JSON.stringify(adminResult)})`, + ).toBe(true); + const adminToken = adminResult?.data?.authorization; + expect(adminToken, "the upstream received the minted bearer").toMatch(/^Bearer .+/); + + // THE guarantee: a different member of the same org resolves the + // same org-owned connection to the same token. Before the fix the + // token rows sat in the consenting user's partition, so this member + // resolved nothing and failed with `oauth_connection_missing`. + const member = yield* Effect.promise(() => + createInvitedIdentity(target.baseUrl, admin, { emailPrefix: "org-oauth-member" }), + ); + const memberClient = yield* makeClient(api, member); + const memberResult = yield* invoke(memberClient, "other member"); + expect( + memberResult?.ok, + "another org member's tool call resolves the same org connection " + + `(got: ${JSON.stringify(memberResult)})`, + ).toBe(true); + expect( + memberResult?.data?.authorization, + "both members resolve the same org-shared token", + ).toBe(adminToken); + }), + Effect.gen(function* () { + yield* adminClient.connections + .remove({ params: { owner: "org", integration, name: connectionName } }) + .pipe(Effect.ignore); + yield* adminClient.oauth + .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* adminClient.openapi + .removeSpec({ params: { slug: integration } }) + .pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/e2e/targets/selfhost.ts b/e2e/targets/selfhost.ts index 008142cca8..add1d0bb4d 100644 --- a/e2e/targets/selfhost.ts +++ b/e2e/targets/selfhost.ts @@ -2,6 +2,8 @@ // on a throwaway data dir, with Better Auth + the bootstrap admin. MCP OAuth is // headless via `forcedMcpConsent` below. Boot lives in // setup/selfhost.globalsetup.ts. +import { randomBytes } from "node:crypto"; + import { Effect } from "effect"; import { e2ePort } from "../src/ports"; @@ -99,6 +101,53 @@ export const forcedMcpConsent = return { code }; }; +// A second, distinct member of the (single) selfhost org: admin invite → +// invited email signup → Better Auth session. What multi-user scenarios use +// until per-test invite-signup isolation lands in newIdentity itself. +export const createInvitedIdentity = async ( + baseUrl: string, + admin: Identity, + options?: { readonly role?: "admin" | "member"; readonly emailPrefix?: string }, +): Promise => { + const cookie = admin.headers?.cookie; + if (typeof cookie !== "string") { + throw new Error("createInvitedIdentity: the admin identity has no Better Auth session cookie"); + } + const origin = new URL(baseUrl).origin; + + const invite = await fetch(new URL("/api/admin/invites", baseUrl), { + method: "POST", + headers: { "content-type": "application/json", cookie, origin }, + body: JSON.stringify({ role: options?.role ?? "member" }), + }); + if (invite.status !== 200) { + throw new Error(`createInvitedIdentity: invite create failed (${await invite.text()})`); + } + const inviteBody = (await invite.json()) as { readonly code?: string }; + if (typeof inviteBody.code !== "string") { + throw new Error("createInvitedIdentity: invite response carried no redeemable code"); + } + + const email = `${options?.emailPrefix ?? "invited-member"}-${randomBytes(5).toString("hex")}@e2e.test`; + const password = "invited-member-password-123"; + const signup = await fetch(new URL("/api/auth/sign-up/email", baseUrl), { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ email, password, name: email, inviteCode: inviteBody.code }), + }); + if (signup.status !== 200) { + throw new Error(`createInvitedIdentity: invited signup failed (${await signup.text()})`); + } + + const session = await signInSession(baseUrl, { email, password }); + return { + label: email, + credentials: { email, password }, + headers: { cookie: session.cookieHeader }, + cookies: session.cookies, + }; +}; + export const selfhostTarget = (): Target => ({ name: "selfhost", baseUrl: SELFHOST_BASE_URL, diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 73a8e9f6c8..0d04e3f390 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -162,6 +162,14 @@ export { type ExecutorOwnerPolicyContext, } from "./owner-policy"; +// Provider item-id owner grammar — the partition credential providers file +// rows under (issues #950, #1453). +export { + OWNER_SCOPED_ITEM_ID_PREFIXES, + embeddedItemOwner, + ownerForItemId, +} from "./provider-item-owner"; + // Tool policies. export { matchPattern, diff --git a/packages/core/sdk/src/provider-item-owner.ts b/packages/core/sdk/src/provider-item-owner.ts new file mode 100644 index 0000000000..74e857fdb1 --- /dev/null +++ b/packages/core/sdk/src/provider-item-owner.ts @@ -0,0 +1,47 @@ +// --------------------------------------------------------------------------- +// The owner a provider item id embeds — the single parser for the id grammar +// the SDK constructs (accessItemId in oauth-service.ts, the connection value +// ids in executor.ts, the oauth-client secret ids): +// +// connection:::: +// oauth:::[:refresh] +// oauth-client:::secret +// +// Credential providers file plugin-storage rows by THIS owner, not the acting +// caller's binding — an org connection whose OAuth consent completes in one +// member's browser session must produce rows every member can resolve +// (issues #950, #1453). +// --------------------------------------------------------------------------- + +import { Owner } from "./ids"; +import type { OwnerBinding } from "./plugin"; + +/** Item-id prefixes whose SECOND colon-segment is the owning partition. */ +export const OWNER_SCOPED_ITEM_ID_PREFIXES: ReadonlySet = new Set([ + "connection", + "oauth", + "oauth-client", +]); + +/** The owner a logical item id embeds, or null for ids that carry none + * (legacy random `secret_*` ids). Reads the second colon-segment of the + * owner-scoped prefixes. */ +export const embeddedItemOwner = (id: string): Owner | null => { + const [prefix, owner] = id.split(":"); + if (OWNER_SCOPED_ITEM_ID_PREFIXES.has(prefix ?? "") && (owner === "org" || owner === "user")) { + return Owner.make(owner); + } + return null; +}; + +/** Map the executor's (tenant, subject?) binding onto the storage `Owner` + * literal: a bound subject writes the user's own partition, otherwise the + * org-shared one. Fallback only — prefer `ownerForItemId`. */ +const ownerOf = (binding: OwnerBinding): Owner => + binding.subject == null ? Owner.make("org") : Owner.make("user"); + +/** The partition a credential belongs to: the CREDENTIAL's owner (embedded in + * the item id), not the acting caller's binding. Ids without an embedded + * owner fall back to the caller binding. */ +export const ownerForItemId = (id: string, binding: OwnerBinding): Owner => + embeddedItemOwner(id) ?? ownerOf(binding); diff --git a/packages/plugins/encrypted-secrets/src/index.test.ts b/packages/plugins/encrypted-secrets/src/index.test.ts index 42ef881398..43b1693fba 100644 --- a/packages/plugins/encrypted-secrets/src/index.test.ts +++ b/packages/plugins/encrypted-secrets/src/index.test.ts @@ -59,7 +59,10 @@ const makeFakeStorage = () => { }), remove: (input: { collection: string; key: string; owner: Owner }) => Effect.sync(() => { - rows.delete(composite(input.collection, input.key)); + // Owner-filtered like the real facade: a remove aimed at the wrong + // partition deletes nothing. + const row = rows.get(composite(input.collection, input.key)); + if (row && row.owner === input.owner) rows.delete(composite(input.collection, input.key)); }), }; return { facade, rows }; @@ -135,7 +138,8 @@ describe("provider", () => { // the scope arg entirely. The provider keys solely by the opaque // `ProviderItemId`; the referencing connection row owns the (tenant, owner, // subject) partition, so cross-scope isolation is no longer the provider's - // concern to enforce or test. + // concern to enforce or test. Filing rows into the RIGHT partition still is + // — see the partitioning describe below (issue #1453). test("get returns null for a missing id", async () => { const { provider } = makeProvider("master"); @@ -162,3 +166,50 @@ describe("provider", () => { expect(entries.map((e) => e.id).sort()).toEqual(["alpha", "beta"]); }); }); + +// The partition a write files under is the CREDENTIAL's owner (embedded in +// the item id), not the acting caller's binding. Issue #1453: a user-bound +// caller completing an org connection's OAuth consent filed the tokens under +// their private partition, so every other org principal resolved nothing and +// failed with `oauth_connection_missing`. +describe("owner partitioning", () => { + const storedOwner = ( + rows: Map, + key: string, + ) => [...rows.values()].find((row) => row.key === key)?.owner; + + test("an org-embedded id written by a user-bound caller files org-shared", async () => { + const { provider, rows } = makeProvider("master", Owner.make("user")); + await Effect.runPromise(provider.set!(id("oauth:org:slack:workspace"), "access_tok")); + await Effect.runPromise(provider.set!(id("oauth:org:slack:workspace:refresh"), "refresh_tok")); + await Effect.runPromise(provider.set!(id("connection:org:exa:main:token"), "api_key")); + await Effect.runPromise(provider.set!(id("oauth-client:org:my_app:secret"), "client_sec")); + expect(storedOwner(rows, "oauth:org:slack:workspace")).toBe("org"); + expect(storedOwner(rows, "oauth:org:slack:workspace:refresh")).toBe("org"); + expect(storedOwner(rows, "connection:org:exa:main:token")).toBe("org"); + expect(storedOwner(rows, "oauth-client:org:my_app:secret")).toBe("org"); + }); + + test("a user-embedded id written by an org-bound caller files user-private", async () => { + const { provider, rows } = makeProvider("master", Owner.make("org")); + await Effect.runPromise(provider.set!(id("connection:user:notion:personal:token"), "private")); + expect(storedOwner(rows, "connection:user:notion:personal:token")).toBe("user"); + }); + + test("an id with no embedded owner falls back to the caller binding", async () => { + const asUser = makeProvider("master", Owner.make("user")); + await Effect.runPromise(asUser.provider.set!(id("github"), "v")); + expect(storedOwner(asUser.rows, "github")).toBe("user"); + + const asOrg = makeProvider("master", Owner.make("org")); + await Effect.runPromise(asOrg.provider.set!(id("github"), "v")); + expect(storedOwner(asOrg.rows, "github")).toBe("org"); + }); + + test("delete targets the embedded owner's partition", async () => { + const { provider, rows } = makeProvider("master", Owner.make("user")); + await Effect.runPromise(provider.set!(id("oauth:org:slack:workspace"), "tok")); + await Effect.runPromise(provider.delete!(id("oauth:org:slack:workspace"))); + expect(rows.size).toBe(0); + }); +}); diff --git a/packages/plugins/encrypted-secrets/src/index.ts b/packages/plugins/encrypted-secrets/src/index.ts index 8447fb7f01..feae781e8a 100644 --- a/packages/plugins/encrypted-secrets/src/index.ts +++ b/packages/plugins/encrypted-secrets/src/index.ts @@ -4,7 +4,7 @@ import { Effect } from "effect"; import { definePlugin, - Owner, + ownerForItemId, ProviderItemId, ProviderKey, StorageError, @@ -29,8 +29,8 @@ import { // v2: the provider sees only an opaque `ProviderItemId` — there is NO scope // arg. The connection row that references the id owns the (tenant, owner, // subject) partition; the encrypted value is keyed solely by the opaque id. -// Plugin storage writes still carry an `owner` (the executor's binding), which -// is captured once from the ctx at provider construction. +// Plugin storage writes carry an `owner`: the one embedded in the item id +// (`oauth:org:…` → org-shared) when present, else the executor's binding. // --------------------------------------------------------------------------- type PluginStorage = PluginCtx["pluginStorage"]; @@ -78,16 +78,10 @@ const decryptSecret = (key: Buffer, payload: string): Effect.Effect - binding.subject == null ? Owner.make("org") : Owner.make("user"); - const makeEncryptedProvider = ( key: Buffer, storage: PluginStorage, - owner: Owner, + binding: OwnerBinding, ): CredentialProvider => ({ key: ENCRYPTED_PROVIDER_KEY, writable: true, @@ -105,12 +99,18 @@ const makeEncryptedProvider = ( set: (id: ProviderItemId, value: string) => encryptSecret(key, value).pipe( Effect.flatMap((payload) => - storage.put({ collection: COLLECTION, key: id, owner, data: payload }), + storage.put({ + collection: COLLECTION, + key: id, + owner: ownerForItemId(id, binding), + data: payload, + }), ), Effect.asVoid, ), - delete: (id: ProviderItemId) => storage.remove({ collection: COLLECTION, key: id, owner }), + delete: (id: ProviderItemId) => + storage.remove({ collection: COLLECTION, key: id, owner: ownerForItemId(id, binding) }), list: () => storage @@ -143,10 +143,17 @@ export const encryptedSecretsPlugin = definePlugin((options?: EncryptedSecretsPl id: "encryptedSecrets" as const, storage: () => ({}), credentialProviders: (ctx: PluginCtx): readonly CredentialProvider[] => [ - makeEncryptedProvider(derivedKey, ctx.pluginStorage, ownerOf(ctx.owner)), + makeEncryptedProvider(derivedKey, ctx.pluginStorage, ctx.owner), ], }; }); // Exported for host-side tests / reuse. export { deriveKey, encryptSecret, decryptSecret }; + +// Boot-time repair for rows the pre-fix provider filed under the acting +// caller's partition (issue #1453). +export { + encryptedSecretsRepartitionDataMigration, + runSqliteEncryptedSecretsRepartition, +} from "./repartition-migration"; diff --git a/packages/plugins/encrypted-secrets/src/repartition-migration.test.ts b/packages/plugins/encrypted-secrets/src/repartition-migration.test.ts new file mode 100644 index 0000000000..bd61416054 --- /dev/null +++ b/packages/plugins/encrypted-secrets/src/repartition-migration.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit } from "effect"; +import { collectTables, type SqliteDataMigrationClient } from "@executor-js/sdk"; +import { createSqliteTestFumaDb } from "@executor-js/sdk/testing"; + +import { runSqliteEncryptedSecretsRepartition } from "./repartition-migration"; + +// The real plugin_storage schema (unique on tenant/owner/subject/plugin_id/ +// collection/key), so the insert-or-ignore + delete choreography is exercised +// against the production shape. +const makeDb = () => Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); + +type Row = { + readonly rowId: string; + readonly owner: string; + readonly subject: string; + readonly key: string; + readonly data?: string; + readonly pluginId?: string; + readonly collection?: string; +}; + +const insertRow = (client: SqliteDataMigrationClient, row: Row) => + Effect.promise(() => + client.execute({ + sql: `INSERT INTO plugin_storage + (row_id, tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at) + VALUES (?, 'tenant-a', ?, ?, ?, ?, ?, ?, 1, 1)`, + args: [ + row.rowId, + row.owner, + row.subject, + row.pluginId ?? "encryptedSecrets", + row.collection ?? "secrets", + row.key, + JSON.stringify(row.data ?? "v1.iv.tag.ct"), + ], + }), + ); + +const partitions = (client: SqliteDataMigrationClient) => + Effect.promise(() => + client.execute("SELECT owner, subject, key, data FROM plugin_storage ORDER BY key"), + ).pipe( + Effect.map((result) => + // `data` is a json column; compare its raw stored text. + result.rows.map((row) => ({ + owner: row.owner, + subject: row.subject, + key: row.key, + data: String(row.data), + })), + ), + ); + +describe("runSqliteEncryptedSecretsRepartition", () => { + it.effect("moves org-embedded rows out of a user partition, ciphertext intact", () => + Effect.gen(function* () { + const db = yield* makeDb(); + yield* insertRow(db.client, { + rowId: "r1", + owner: "user", + subject: "user-123", + key: "oauth:org:linear:main", + }); + yield* insertRow(db.client, { + rowId: "r2", + owner: "user", + subject: "user-123", + key: "oauth:org:linear:main:refresh", + }); + const moved = yield* runSqliteEncryptedSecretsRepartition(db.client); + expect(moved).toBe(2); + expect(yield* partitions(db.client)).toEqual([ + { owner: "org", subject: "", key: "oauth:org:linear:main", data: '"v1.iv.tag.ct"' }, + { owner: "org", subject: "", key: "oauth:org:linear:main:refresh", data: '"v1.iv.tag.ct"' }, + ]); + }), + ); + + it.effect("leaves correctly filed rows, user-embedded ids, and unscoped ids alone", () => + Effect.gen(function* () { + const db = yield* makeDb(); + yield* insertRow(db.client, { + rowId: "r1", + owner: "org", + subject: "", + key: "oauth:org:linear:main", + }); + yield* insertRow(db.client, { + rowId: "r2", + owner: "user", + subject: "user-123", + key: "connection:user:notion:p:token", + }); + yield* insertRow(db.client, { + rowId: "r3", + owner: "user", + subject: "user-123", + key: "legacy-random-id", + }); + const moved = yield* runSqliteEncryptedSecretsRepartition(db.client); + expect(moved).toBe(0); + expect((yield* partitions(db.client)).map((row) => `${row.owner}/${row.key}`)).toEqual([ + "user/connection:user:notion:p:token", + "user/legacy-random-id", + "org/oauth:org:linear:main", + ]); + }), + ); + + it.effect("a post-fix org row wins over the mis-filed duplicate", () => + Effect.gen(function* () { + const db = yield* makeDb(); + yield* insertRow(db.client, { + rowId: "old", + owner: "user", + subject: "user-123", + key: "oauth:org:linear:main", + data: "v1.old", + }); + yield* insertRow(db.client, { + rowId: "new", + owner: "org", + subject: "", + key: "oauth:org:linear:main", + data: "v1.new", + }); + const moved = yield* runSqliteEncryptedSecretsRepartition(db.client); + expect(moved).toBe(1); + expect(yield* partitions(db.client)).toEqual([ + { owner: "org", subject: "", key: "oauth:org:linear:main", data: '"v1.new"' }, + ]); + }), + ); + + it.effect("does not touch other plugins' rows", () => + Effect.gen(function* () { + const db = yield* makeDb(); + yield* insertRow(db.client, { + rowId: "r1", + owner: "user", + subject: "user-123", + key: "oauth:org:slack:main", + pluginId: "workosVault", + collection: "metadata", + }); + const moved = yield* runSqliteEncryptedSecretsRepartition(db.client); + expect(moved).toBe(0); + expect((yield* partitions(db.client))[0]).toMatchObject({ + owner: "user", + subject: "user-123", + }); + }), + ); + + it.effect("fails loudly on an unreadable row instead of skipping it", () => + Effect.gen(function* () { + const db = yield* makeDb(); + yield* insertRow(db.client, { + rowId: "r1", + owner: "user", + subject: "user-123", + key: "oauth:org:linear:main", + }); + const scripted: SqliteDataMigrationClient = { + execute: (stmt) => { + const sql = typeof stmt === "string" ? stmt : stmt.sql; + if (sql.includes("SELECT row_id")) { + return Promise.resolve({ rows: [{ row_id: null, owner: "user", key: null }] }); + } + return db.client.execute(stmt as never) as never; + }, + }; + const exit = yield* Effect.exit(runSqliteEncryptedSecretsRepartition(scripted)); + expect(Exit.isFailure(exit), "an unreadable row fails the migration").toBe(true); + }), + ); + + it.effect("is a no-op on a database without the table", () => + Effect.gen(function* () { + const empty: SqliteDataMigrationClient = { + execute: (stmt) => { + const sql = typeof stmt === "string" ? stmt : stmt.sql; + if (sql.includes("sqlite_master")) return Promise.resolve({ rows: [] }); + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: scripted fake for a promise-shaped client interface + return Promise.reject(new Error(`unexpected statement: ${sql}`)); + }, + }; + const moved = yield* runSqliteEncryptedSecretsRepartition(empty); + expect(moved).toBe(0); + }), + ); + + it.effect("is idempotent", () => + Effect.gen(function* () { + const db = yield* makeDb(); + yield* insertRow(db.client, { + rowId: "r1", + owner: "user", + subject: "user-123", + key: "oauth:org:linear:main", + }); + expect(yield* runSqliteEncryptedSecretsRepartition(db.client)).toBe(1); + expect(yield* runSqliteEncryptedSecretsRepartition(db.client)).toBe(0); + }), + ); +}); diff --git a/packages/plugins/encrypted-secrets/src/repartition-migration.ts b/packages/plugins/encrypted-secrets/src/repartition-migration.ts new file mode 100644 index 0000000000..6a894609e9 --- /dev/null +++ b/packages/plugins/encrypted-secrets/src/repartition-migration.ts @@ -0,0 +1,123 @@ +// --------------------------------------------------------------------------- +// Data migration: re-file mis-partitioned encrypted-secrets rows. +// +// Before the `ownerForItemId` fix (issue #1453), the provider filed every +// credential under the ACTING caller's partition — so an org connection whose +// OAuth consent completed in a user's browser session stored its token rows +// at owner='user', subject=, while the connection row said org. +// The consenting user still resolved the tokens (user rows shadow org rows), +// so the connection looked healthy; every other principal failed with +// `oauth_connection_missing`. Mirrors the WorkOS Vault repair from #950 +// (apps/cloud/scripts/repartition-vault-metadata.ts), but as a boot-time +// ledger entry — the affected hosts (selfhost, Cloudflare) have no operator +// to run a script by hand. Encryption is global-key, not principal-bound, so +// moving a row is a pure partition change; ciphertext decrypts unchanged. +// +// Only org-embedded ids stuck outside the org partition are moved: that is +// the one direction the pre-fix `ownerOf(binding)` could produce (a +// `*:user:*` id can only ever be minted by a subject-bound executor, which +// the old code filed as 'user' — already correct). +// +// Idempotent: already-correct rows never match, and the copy step is +// insert-or-ignore against the (tenant, owner, subject, plugin_id, +// collection, key) unique index — a post-fix write that already created the +// correct row wins and the mis-filed duplicate is simply dropped. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { + DataMigrationError, + embeddedItemOwner, + ORG_SUBJECT, + type SqliteDataMigration, + type SqliteDataMigrationClient, +} from "@executor-js/sdk"; + +const MIGRATION_NAME = "2026-07-27-encrypted-secrets-owner-repartition"; + +const PLUGIN_ID = "encryptedSecrets"; +const COLLECTION = "secrets"; + +const execute = ( + client: SqliteDataMigrationClient, + stmt: string | { readonly sql: string; readonly args: readonly unknown[] }, +) => + Effect.tryPromise({ + try: () => client.execute(stmt), + catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }), + }); + +const tableExists = (client: SqliteDataMigrationClient, table: string) => + Effect.map( + execute(client, { + sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + args: [table], + }), + (result) => result.rows.length > 0, + ); + +/** Move every encrypted-secrets row whose item id embeds `org` but whose + * stored partition is not the org one into `owner='org', subject=''`. + * Returns the number of mis-filed rows repaired (moved or, when a post-fix + * write already created the org row, dropped in its favor). Fresh databases + * lack the table; nothing to do. */ +export const runSqliteEncryptedSecretsRepartition = ( + client: SqliteDataMigrationClient, +): Effect.Effect => + Effect.gen(function* () { + if (!(yield* tableExists(client, "plugin_storage"))) return 0; + + const rows = yield* execute(client, { + sql: `SELECT row_id, owner, subject, key + FROM plugin_storage + WHERE plugin_id = ? AND collection = ?`, + args: [PLUGIN_ID, COLLECTION], + }); + + const misfiled: string[] = []; + for (const row of rows.rows) { + if (typeof row.row_id !== "string" || typeof row.key !== "string") { + return yield* new DataMigrationError({ + migration: MIGRATION_NAME, + cause: `unreadable plugin_storage row: ${JSON.stringify(row)}`, + }); + } + if (embeddedItemOwner(row.key) !== "org") continue; + if (row.owner === "org" && row.subject === ORG_SUBJECT) continue; + misfiled.push(row.row_id); + } + if (misfiled.length === 0) return 0; + + const applyAll = Effect.gen(function* () { + for (const rowId of misfiled) { + // Re-file in place: copy into the org partition (no-op when a + // post-fix write already created it), then drop the mis-filed row. + yield* execute(client, { + sql: `INSERT OR IGNORE INTO plugin_storage + (row_id, tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at) + SELECT row_id || '-repart', tenant, 'org', ?, plugin_id, collection, key, data, created_at, updated_at + FROM plugin_storage WHERE row_id = ?`, + args: [ORG_SUBJECT, rowId], + }); + yield* execute(client, { + sql: "DELETE FROM plugin_storage WHERE row_id = ?", + args: [rowId], + }); + } + yield* execute(client, "COMMIT"); + return misfiled.length; + }); + + yield* execute(client, "BEGIN"); + return yield* applyAll.pipe( + Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)), + ); + }); + +/** Registry entry for the boot-time data-migration ledger. */ +export const encryptedSecretsRepartitionDataMigration: SqliteDataMigration = { + name: MIGRATION_NAME, + run: (client: SqliteDataMigrationClient) => + runSqliteEncryptedSecretsRepartition(client).pipe(Effect.asVoid), +}; diff --git a/packages/plugins/workos-vault/src/sdk/secret-store.ts b/packages/plugins/workos-vault/src/sdk/secret-store.ts index 73e31e7ee1..97341056e8 100644 --- a/packages/plugins/workos-vault/src/sdk/secret-store.ts +++ b/packages/plugins/workos-vault/src/sdk/secret-store.ts @@ -2,7 +2,8 @@ import { Effect, Option, Predicate, Schema } from "effect"; import { type CredentialProvider, - Owner, + embeddedItemOwner, + ownerForItemId, type OwnerBinding, type PluginStorageEntry, ProviderItemId, @@ -88,36 +89,6 @@ const entryToMetadataRow = (entry: PluginStorageEntry): MetadataRow | null => type WorkosVaultMetadataData = typeof WorkosVaultMetadataData.Type; -/** Map the executor's (tenant, subject?) binding onto the storage `Owner` - * literal: a bound subject writes the user's own partition, otherwise the - * org-shared one. Fallback only — prefer `ownerForItemId`. */ -const ownerOf = (binding: OwnerBinding): Owner => - binding.subject == null ? Owner.make("org") : Owner.make("user"); - -// Item ids whose SECOND colon-segment is the owning partition: -// connection:::: -// oauth:::[:refresh] -// oauth-client:::secret -const OWNER_SCOPED_PREFIXES: ReadonlySet = new Set(["connection", "oauth", "oauth-client"]); - -/** The owner a logical item id embeds, or null for ids that carry none - * (legacy random `secret_*` ids). Reads the second colon-segment of the - * owner-scoped prefixes. */ -const embeddedOwner = (id: string): Owner | null => { - const [prefix, owner] = id.split(":"); - if (OWNER_SCOPED_PREFIXES.has(prefix ?? "") && (owner === "org" || owner === "user")) { - return Owner.make(owner); - } - return null; -}; - -/** The partition a credential's metadata belongs to: the CREDENTIAL's owner - * (embedded in the item id), not the acting caller's binding — so an org - * member's workspace connection files org-shared metadata that every member - * can resolve. Ids without an embedded owner fall back to the caller binding. */ -const ownerForItemId = (id: string, binding: OwnerBinding): Owner => - embeddedOwner(id) ?? ownerOf(binding); - // --------------------------------------------------------------------------- // WorkosVaultStore — typed metadata-store the plugin uses internally. // @@ -225,7 +196,7 @@ const sha256Base64Url = (input: string): Effect.Effect => /** Partition path segments for a logical item id, or null for legacy ids that * carry no embedded owner (those keep the flat, unscoped name). */ const objectScopeSegments = (id: string, binding: OwnerBinding): readonly string[] | null => { - const owner = embeddedOwner(id); + const owner = embeddedItemOwner(id); if (owner === null) return null; const tenant = encodeObjectNameSegment(String(binding.tenant)); return owner === "user" @@ -250,7 +221,7 @@ const secretObjectName = ( * per-user) context so WorkOS provisions an isolated KEK per partition; legacy * ids keep the original shared context so existing objects stay decryptable. */ const vaultContextFor = (id: string, binding: OwnerBinding): Record => { - const owner = embeddedOwner(id); + const owner = embeddedItemOwner(id); if (owner === null) return { app: "executor" }; const context: Record = { app: "executor", From cbf25cbe3f836e30a7e74c02237fa1ac661191e2 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:06:13 -0700 Subject: [PATCH 003/328] Validate OAuth discovery metadata identity (RFC 8414 / RFC 9728 / RFC 8707) (#1457) * fix oauth discovery metadata validation * Add emulator-backed DCR compliance e2e scenarios Four scenarios against the MCP emulator's new oauth misbehavior knobs (emulate 0.13.7): issuer-mismatch rejection (RFC 8414), foreign-resource rejection (RFC 9728), client_secret_basic negotiation when the metadata omits auth methods, and honoring a server-substituted auth method (RFC 7591). The first two pass with the discovery validation on this branch; the last two stay red until auth-method negotiation is fixed. * Address review: typed e2e assertions, hoisted issuer canonicalizer The e2e probe-failure assertions now narrow with Predicate.isTagged and assert the typed message instead of stringifying a cast. canonicalIssuer is hoisted to module scope with the issuer-vs-resource canonicalization difference documented in one place, and the DCR resource canonicalization records why legacy raw-string rows need no backfill. --- bun.lock | 4 +- e2e/package.json | 2 +- e2e/scenarios/oauth-dcr-compliance.test.ts | 328 ++++++++++++++++++ packages/core/sdk/src/oauth-discovery.test.ts | 83 ++++- packages/core/sdk/src/oauth-discovery.ts | 39 ++- .../sdk/src/oauth-register-dynamic.test.ts | 69 +++- packages/core/sdk/src/oauth-service.ts | 31 +- .../core/sdk/src/testing/oauth-test-server.ts | 8 +- 8 files changed, 534 insertions(+), 30 deletions(-) create mode 100644 e2e/scenarios/oauth-dcr-compliance.test.ts diff --git a/bun.lock b/bun.lock index bc5ca40ea5..77798579a1 100644 --- a/bun.lock +++ b/bun.lock @@ -343,7 +343,7 @@ "version": "0.0.32", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.6", + "@executor-js/emulate": "^0.13.7", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -1741,7 +1741,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.13.6", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-FwR2RvO5DnwYgGO2w3JKPCpUx8o+AzSPJeei/oYMIrb4PYg2bQceucxiw52698t9SDEDwAOHkdC/Vv1Tv1+MJQ=="], + "@executor-js/emulate": ["@executor-js/emulate@0.13.7", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-119UmWRSAtagLWdH8a53X/X1/q1YjM4O3Gkp2STy4/DeSNg7CTIF4XV7gNiNTAed90iLfte1vQzTwh1o/O5aWg=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], diff --git a/e2e/package.json b/e2e/package.json index ca937fc190..1840ee1168 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.6", + "@executor-js/emulate": "^0.13.7", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/e2e/scenarios/oauth-dcr-compliance.test.ts b/e2e/scenarios/oauth-dcr-compliance.test.ts new file mode 100644 index 0000000000..bf4aefb770 --- /dev/null +++ b/e2e/scenarios/oauth-dcr-compliance.test.ts @@ -0,0 +1,328 @@ +// Cross-target: DCR (RFC 7591) compliance against a real emulated MCP +// authorization server — the probe → registerDynamic → start → complete +// journey driven through the product's typed API, with the MCP emulator +// seeded into each misbehavior/variation an RFC-compliant client must handle. +// +// Each scenario is one gap from the 2026-07 DCR spec audit: +// 1. RFC 8414 §3.3 — a metadata document whose `issuer` does not match the +// URL it was fetched from must be REJECTED at probe time. +// 2. RFC 9728 §3.3 — protected-resource metadata whose `resource` is not +// the requested identifier must be rejected, never adopted as the +// RFC 8707 token audience. +// 3. RFC 8414 §2 — omitted `token_endpoint_auth_methods_supported` means +// client_secret_basic, NOT `none`; a registration + token exchange +// against such a server must authenticate with HTTP Basic. +// 4. RFC 7591 §3.2.1 — the server may substitute registration metadata; +// the client must honor the RETURNED token_endpoint_auth_method (a +// server answering client_secret_basic gets Basic on /token, even when +// the client asked for `none`). +import { randomBytes } from "node:crypto"; +import { createServer } from "node:net"; + +import { expect } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { createEmulator, type Emulator } from "@executor-js/emulate"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import type { OAuthProbeError } from "@executor-js/sdk"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +/** The slug `normalizeMcpAuthMethods` assigns a slug-less oauth2 method. */ +const OAUTH2_TEMPLATE = AuthTemplateSlug.make("oauth2"); + +const availablePort = Effect.callback((resume) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => { + resume(Effect.succeed(port)); + }); + }); +}); + +/** A private MCP emulator seeded with `oauth` misbehavior knobs plus one + * authorizable user. Local + in-process so each scenario owns its instance. */ +const mcpEmulator = (oauth: Record) => + Effect.acquireRelease( + Effect.gen(function* () { + const port = yield* availablePort; + const emulator = yield* Effect.promise(() => createEmulator({ service: "mcp", port })); + yield* Effect.promise(() => emulator.seed({ users: [{ login: "octocat" }], oauth })); + return emulator; + }), + (emulator: Emulator) => Effect.promise(() => emulator.close()).pipe(Effect.ignore), + ); + +/** Follow the emulator's consent screen headlessly: GET /authorize renders a + * form per seeded user; POST the approval and capture the redirect back to + * the executor callback. Returns the `code` + `state` from that location. */ +const approveConsent = (authorizationUrl: string) => + Effect.promise(async () => { + const authorize = new URL(authorizationUrl); + const page = await fetch(authorize, { redirect: "manual" }); + if (!page.ok) throw new Error(`authorize page failed: ${page.status}`); + const approval = new URL("/authorize/approve", authorize.origin); + const body = new URLSearchParams({ + client_id: authorize.searchParams.get("client_id") ?? "", + redirect_uri: authorize.searchParams.get("redirect_uri") ?? "", + state: authorize.searchParams.get("state") ?? "", + scope: authorize.searchParams.get("scope") ?? "", + code_challenge: authorize.searchParams.get("code_challenge") ?? "", + code_challenge_method: authorize.searchParams.get("code_challenge_method") ?? "", + resource: authorize.searchParams.get("resource") ?? "", + login: "octocat", + }); + const response = await fetch(approval, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: body.toString(), + redirect: "manual", + }); + const location = response.headers.get("location"); + if (response.status !== 302 || !location) { + throw new Error(`consent approval did not redirect: ${response.status}`); + } + const redirected = new URL(location); + const code = redirected.searchParams.get("code"); + const state = redirected.searchParams.get("state"); + if (!code || !state) throw new Error(`callback carried no code/state: ${location}`); + return { code, state }; + }); + +// --------------------------------------------------------------------------- +// 1. RFC 8414 issuer validation: metadata lying about its issuer is rejected. +// --------------------------------------------------------------------------- + +scenario( + "OAuth DCR · probe rejects authorization-server metadata whose issuer mismatches", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const emulator = yield* mcpEmulator({ issuerOverride: "https://evil.example.com" }); + + // The metadata document at /.well-known/... claims an issuer + // it does not live at. RFC 8414 §3.3: such metadata MUST NOT be used. + const failure = yield* Effect.flip( + client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }), + ); + expect(Predicate.isTagged("OAuthProbeError")(failure)).toBe(true); + const probeError = failure as OAuthProbeError; + expect(probeError.message).toContain("issuer"); + expect(probeError.message).toContain("https://evil.example.com"); + }), + ), +); + +// --------------------------------------------------------------------------- +// 2. RFC 9728 resource validation: PRM naming a foreign resource is rejected. +// --------------------------------------------------------------------------- + +scenario( + "OAuth DCR · probe rejects protected-resource metadata naming a foreign resource", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const emulator = yield* mcpEmulator({ + resourceOverride: "https://other.example.com/mcp", + }); + + // The PRM's `resource` is not the identifier we asked about. Accepting + // it would make the foreign URL the RFC 8707 token audience. + const failure = yield* Effect.flip( + client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }), + ); + expect(Predicate.isTagged("OAuthProbeError")(failure)).toBe(true); + const probeError = failure as OAuthProbeError; + expect(probeError.message).toContain("resource"); + expect(probeError.message).toContain("https://other.example.com/mcp"); + }), + ), +); + +// --------------------------------------------------------------------------- +// 3. RFC 8414 default auth method: omitted advertisement = client_secret_basic. +// The emulator omits token_endpoint_auth_methods_supported entirely and its +// token endpoint REQUIRES HTTP Basic for basic clients — a client that +// wrongly assumes `none` (or posts the secret in the form body) fails. +// --------------------------------------------------------------------------- + +scenario( + "OAuth DCR · omitted auth-methods metadata negotiates client_secret_basic end to end", + { + skip: "red until DCR auth-method negotiation honors the RFC 8414 client_secret_basic default (needs the oauth_client token_endpoint_auth_method column)", + }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const emulator = yield* mcpEmulator({ tokenEndpointAuthMethods: "omit" }); + + const integration = IntegrationSlug.make(unique("dcr-basic")); + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "DCR compliance MCP", + endpoint: `${emulator.url}/mcp`, + slug: String(integration), + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + const probe = yield* client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }); + expect(probe.registrationEndpoint, "the emulator advertises DCR").toBeTruthy(); + // RFC 8414 §2: omitted means client_secret_basic — the probe must not + // fabricate a `none` capability the server never advertised. + expect(probe.tokenEndpointAuthMethodsSupported ?? null).toBeNull(); + + const registered = yield* client.oauth.registerDynamic({ + payload: { + owner: "user", + slug: OAuthClientSlug.make(unique("dcr-basic")), + issuer: probe.issuer ?? null, + registrationEndpoint: probe.registrationEndpoint!, + authorizationUrl: probe.authorizationUrl, + tokenUrl: probe.tokenUrl, + resource: probe.resource ?? null, + scopes: [...(probe.scopesSupported ?? [])], + tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, + clientName: "Executor", + originIntegration: integration, + }, + }); + + const started = yield* client.oauth.start({ + payload: { + client: registered.client, + clientOwner: "user", + owner: "user", + name: ConnectionName.make("main"), + integration, + template: OAUTH2_TEMPLATE, + }, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + // The token exchange must authenticate with HTTP Basic (the emulator + // rejects form-body secrets for basic clients) — completing proves the + // negotiated AND executed method is client_secret_basic. + const { code } = yield* approveConsent(started.authorizationUrl); + const connection = yield* client.oauth.complete({ + payload: { state: started.state, code }, + }); + expect(String(connection.name)).toBe("main"); + + // Completion alone is not proof: the emulator honors whatever method the + // client REQUESTED at registration, so a wrongly-negotiated `none` also + // completes (public client + PKCE). The on-wire /token request is the + // authoritative check: a Basic client sends an Authorization header + // (redacted in the ledger, but its presence survives), a `none` client + // sends none at all. + const ledger = yield* Effect.promise(() => emulator.ledger.list()); + const tokenExchange = ledger.find( + (entry) => + entry.method === "POST" && + entry.path.endsWith("/token") && + JSON.stringify(entry.request.body ?? "").includes("authorization_code"), + ); + expect(tokenExchange, "the token exchange is in the ledger").toBeDefined(); + expect( + Object.keys(tokenExchange!.request.headers).map((h) => h.toLowerCase()), + "the token exchange authenticated with HTTP Basic", + ).toContain("authorization"); + }), + ), +); + +// --------------------------------------------------------------------------- +// 4. RFC 7591 metadata substitution: the server upgrades the requested `none` +// to client_secret_basic in the registration RESPONSE; the client must use +// the returned method on /token, not the one it asked for. +// --------------------------------------------------------------------------- + +scenario( + "OAuth DCR · the server-substituted token auth method is honored on the token exchange", + { + skip: "red until the DCR response's token_endpoint_auth_method is persisted and used on /token (needs the oauth_client token_endpoint_auth_method column)", + }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const emulator = yield* mcpEmulator({ dcrAuthMethodOverride: "client_secret_basic" }); + + const integration = IntegrationSlug.make(unique("dcr-subst")); + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "DCR substitution MCP", + endpoint: `${emulator.url}/mcp`, + slug: String(integration), + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + const probe = yield* client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }); + + // The server advertises `none` (so the client requests it) but the DCR + // response substitutes client_secret_basic + mints a secret. + const registered = yield* client.oauth.registerDynamic({ + payload: { + owner: "user", + slug: OAuthClientSlug.make(unique("dcr-subst")), + issuer: probe.issuer ?? null, + registrationEndpoint: probe.registrationEndpoint!, + authorizationUrl: probe.authorizationUrl, + tokenUrl: probe.tokenUrl, + resource: probe.resource ?? null, + scopes: [...(probe.scopesSupported ?? [])], + tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, + clientName: "Executor", + originIntegration: integration, + }, + }); + + const started = yield* client.oauth.start({ + payload: { + client: registered.client, + clientOwner: "user", + owner: "user", + name: ConnectionName.make("main"), + integration, + template: OAUTH2_TEMPLATE, + }, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + const { code } = yield* approveConsent(started.authorizationUrl); + const connection = yield* client.oauth.complete({ + payload: { state: started.state, code }, + }); + expect(String(connection.name)).toBe("main"); + }), + ), +); diff --git a/packages/core/sdk/src/oauth-discovery.test.ts b/packages/core/sdk/src/oauth-discovery.test.ts index 7e30d19777..5badf0b6bc 100644 --- a/packages/core/sdk/src/oauth-discovery.test.ts +++ b/packages/core/sdk/src/oauth-discovery.test.ts @@ -79,14 +79,14 @@ const withOAuthFixture = ( ); describe("canonicalResourceUrl", () => { - it("lowercases scheme + host, drops trailing slash, fragment, and query", () => { + it("lowercases scheme + host, keeps query + trailing slash, and drops fragment", () => { expect(canonicalResourceUrl("https://API.Example.com/v1/mcp/")).toBe( - "https://api.example.com/v1/mcp", + "https://api.example.com/v1/mcp/", ); expect(canonicalResourceUrl("HTTPS://api.example.com/v1/mcp?x=1#frag")).toBe( - "https://api.example.com/v1/mcp", + "https://api.example.com/v1/mcp?x=1", ); - expect(canonicalResourceUrl("https://api.example.com/")).toBe("https://api.example.com"); + expect(canonicalResourceUrl("https://api.example.com/")).toBe("https://api.example.com/"); }); }); @@ -99,7 +99,7 @@ describe("discoverProtectedResourceMetadata", () => { } if (request.url === "/.well-known/oauth-protected-resource") { return sendJson({ - resource: baseUrl, + resource: `${baseUrl}/graphql`, authorization_servers: [baseUrl], scopes_supported: ["read"], }); @@ -170,6 +170,22 @@ describe("discoverAuthorizationServerMetadata", () => { ), ); + it.effect("treats a trailing slash as the same authorization-server issuer", () => + withOAuthFixture( + (_request, baseUrl) => + sendJson({ + issuer: `${baseUrl}/`, + authorization_endpoint: `${baseUrl}/authorize`, + token_endpoint: `${baseUrl}/token`, + }), + ({ baseUrl }) => + Effect.gen(function* () { + const result = yield* discoverAuthorizationServerMetadata(baseUrl); + expect(result?.metadata.issuer).toBe(`${baseUrl}/`); + }), + ), + ); + it.effect("requires issuer + authorize + token endpoints", () => withOAuthFixture( () => sendJson({ issuer: "http://127.0.0.1" }), @@ -183,9 +199,9 @@ describe("discoverAuthorizationServerMetadata", () => { it.effect("rejects unsupported authorization server endpoint URLs", () => withOAuthFixture( - () => + (_request, baseUrl) => sendJson({ - issuer: "http://example.com", + issuer: baseUrl, authorization_endpoint: "javascript:alert(1)", token_endpoint: "http://example.com/token", response_types_supported: ["code"], @@ -208,9 +224,9 @@ describe("discoverAuthorizationServerMetadata", () => { it.effect("allows HTTP authorization server endpoints when the caller opts into local HTTP", () => withOAuthFixture( - () => + (_request, baseUrl) => sendJson({ - issuer: "http://example.com", + issuer: baseUrl, authorization_endpoint: "http://example.com/authorize", token_endpoint: "http://example.com/token", response_types_supported: ["code"], @@ -329,7 +345,7 @@ describe("beginDynamicAuthorization", () => { } if (request.url === "/.well-known/oauth-protected-resource") { return sendJson({ - resource: baseUrl, + resource: `${baseUrl}/graphql/v2`, authorization_servers: [baseUrl], scopes_supported: ["openid", "profile", "email", "offline_access", "workspace:member"], }); @@ -372,11 +388,11 @@ describe("beginDynamicAuthorization", () => { expect(url.searchParams.get("response_type")).toBe("code"); expect(url.searchParams.get("state")).toBe("state-xyz"); expect(url.searchParams.get("code_challenge_method")).toBe("S256"); - expect(url.searchParams.get("resource")).toBe(baseUrl); + expect(url.searchParams.get("resource")).toBe(`${baseUrl}/graphql/v2`); expect(result.state.authorizationServerMetadata.token_endpoint).toBe( `${baseUrl}/oauth/token`, ); - expect(result.state.resourceMetadata?.resource).toBe(baseUrl); + expect(result.state.resourceMetadata?.resource).toBe(`${baseUrl}/graphql/v2`); }), ), ); @@ -621,7 +637,7 @@ describe("beginDynamicAuthorization", () => { }); const authUrl = new URL(result.authorizationUrl); - expect(authUrl.searchParams.get("resource")).toBe(`${baseUrl}/v1/mcp`); + expect(authUrl.searchParams.get("resource")).toBe(`${baseUrl}/v1/mcp/`); }), ), ); @@ -900,6 +916,47 @@ describe("beginDynamicAuthorization", () => { ), ); + it.effect("rejects an ancestor resource indicator from previous discovery state", () => + withOAuthFixture( + () => notFound(), + ({ baseUrl, requests }) => + Effect.gen(function* () { + const exit = yield* Effect.exit( + beginDynamicAuthorization({ + endpoint: `${baseUrl}/mcp`, + redirectUrl: "https://app/cb", + state: "s", + previousState: { + authorizationServerUrl: baseUrl, + authorizationServerMetadataUrl: `${baseUrl}/.well-known/oauth-authorization-server`, + authorizationServerMetadata: { + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/authorize`, + token_endpoint: `${baseUrl}/token`, + }, + resourceMetadata: null, + clientInformation: { + client_id: "cached-client", + }, + resource: baseUrl, + }, + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + const reason = exit.cause.reasons.find(Cause.isFailReason); + expect(reason?.error).toEqual( + expect.objectContaining({ + _tag: "OAuthDiscoveryError", + message: expect.stringMatching(/resource does not match requested endpoint/), + }), + ); + expect((yield* requests).length).toBe(0); + }), + ), + ); + it.effect("rejects servers that don't support PKCE S256", () => withOAuthFixture( (request, baseUrl) => { diff --git a/packages/core/sdk/src/oauth-discovery.ts b/packages/core/sdk/src/oauth-discovery.ts index 25786012cf..c6e95b8be3 100644 --- a/packages/core/sdk/src/oauth-discovery.ts +++ b/packages/core/sdk/src/oauth-discovery.ts @@ -162,12 +162,32 @@ const validateEndpointUrl = ( }), }); +/** RFC 8414 issuer identity: scheme, host, port, and path after URL + * normalization; a trailing slash alone does not create a different issuer. + * Deliberately NOT `canonicalResourceUrl`: an issuer never carries a query, + * and its trailing slash is insignificant (RFC 8414 §2), whereas a resource + * identifier's query and trailing slash are part of the identity (RFC 8707). */ +const canonicalIssuer = (value: string): string => { + const url = new URL(value); + const path = url.pathname.replace(/\/+$/, ""); + return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${path}`; +}; + const validateAuthorizationServerMetadata = ( metadata: OAuthAuthorizationServerMetadata, + expectedIssuer: string, policy: OAuthEndpointUrlPolicy = {}, ): Effect.Effect => Effect.gen(function* () { yield* validateEndpointUrl(metadata.issuer, "issuer", policy); + if (canonicalIssuer(metadata.issuer) !== canonicalIssuer(expectedIssuer)) { + return yield* new OAuthDiscoveryError({ + message: + `Authorization server metadata issuer ${metadata.issuer} does not match the requested ` + + `issuer ${expectedIssuer}. Correct the server's issuer metadata or use the matching ` + + `authorization server URL.`, + }); + } yield* validateEndpointUrl(metadata.authorization_endpoint, "authorization_endpoint", policy); yield* validateEndpointUrl(metadata.token_endpoint, "token_endpoint", policy); if (metadata.registration_endpoint) { @@ -301,6 +321,9 @@ export const discoverProtectedResourceMetadata = ( }), ), ); + if (metadata.resource) { + yield* validateResourceIndicator(metadata.resource, canonicalResourceUrl(resourceUrl)); + } return { metadataUrl: url, metadata }; } return null; @@ -394,7 +417,7 @@ export const discoverAuthorizationServerMetadata = ( }), ), ); - yield* validateAuthorizationServerMetadata(metadata, options.endpointUrlPolicy); + yield* validateAuthorizationServerMetadata(metadata, issuer, options.endpointUrlPolicy); return { metadataUrl, metadata }; } return null; @@ -583,16 +606,15 @@ export const registerDynamicClient = ( // // MCP Authorization 2025-06-18 requires `resource` on /authorize and /token // requests. RFC 8707 §2 says the value is "an absolute URI" identifying the -// protected resource — same scheme + host + (optional) path, no fragment, -// no query, lowercased scheme/host. +// protected resource. Query components and path trailing slashes are part of +// the identifier; fragments are not sent, and scheme/host compare lowercase. // --------------------------------------------------------------------------- export const canonicalResourceUrl = (value: string): string => { const url = new URL(value); const scheme = url.protocol.toLowerCase(); const host = url.host.toLowerCase(); - const path = url.pathname.replace(/\/+$/, ""); - return `${scheme}//${host}${path}`; + return `${scheme}//${host}${url.pathname}${url.search}`; }; const validateResourceIndicator = ( @@ -608,11 +630,14 @@ const validateResourceIndicator = ( }), }).pipe( Effect.flatMap((actual) => - actual === expected || expected.startsWith(`${actual}/`) + actual === expected ? Effect.succeed(actual) : Effect.fail( new OAuthDiscoveryError({ - message: "Protected resource metadata resource does not match requested endpoint", + message: + `Protected resource metadata resource does not match requested endpoint: ` + + `advertised ${actual}; requested ${expected}. Correct the server's resource ` + + `metadata or use the matching resource URL.`, cause: { expected, actual }, }), ), diff --git a/packages/core/sdk/src/oauth-register-dynamic.test.ts b/packages/core/sdk/src/oauth-register-dynamic.test.ts index 266c7cc4d6..6f69badbb1 100644 --- a/packages/core/sdk/src/oauth-register-dynamic.test.ts +++ b/packages/core/sdk/src/oauth-register-dynamic.test.ts @@ -8,7 +8,7 @@ import { OAuthClientSlug, ToolName, } from "./ids"; -import { OAuthRegisterDynamicError } from "./oauth-client"; +import { OAuthProbeError, OAuthRegisterDynamicError } from "./oauth-client"; import { definePlugin } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; @@ -58,6 +58,73 @@ const oauthPlugin = definePlugin(() => ({ const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; describe("oauth.registerDynamicClient", () => { + it.effect("probe rejects authorization-server metadata with a mismatched issuer", () => + Effect.scoped( + Effect.gen(function* () { + const advertisedIssuer = "https://issuer.example/mismatched"; + const server = yield* serveOAuthTestServer({ advertisedIssuer }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + + const error = yield* Effect.flip(executor.oauth.probe({ url: server.mcpResourceUrl })); + expect(error).toBeInstanceOf(OAuthProbeError); + if (!Predicate.isTagged(error, "OAuthProbeError")) return; + // oxlint-disable-next-line executor/no-unknown-error-message -- test: the tag guard narrows to the public OAuthProbeError contract + const message = error.message; + expect(message).toContain(server.issuerUrl); + expect(message).toContain(advertisedIssuer); + }), + ), + ); + + it.effect("probe rejects protected-resource metadata for a different resource", () => + Effect.scoped( + Effect.gen(function* () { + const advertisedResource = "https://resource.example/mismatched"; + const server = yield* serveOAuthTestServer({ advertisedResource }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + + const error = yield* Effect.flip(executor.oauth.probe({ url: server.mcpResourceUrl })); + expect(error).toBeInstanceOf(OAuthProbeError); + if (!Predicate.isTagged(error, "OAuthProbeError")) return; + // oxlint-disable-next-line executor/no-unknown-error-message -- test: the tag guard narrows to the public OAuthProbeError contract + const message = error.message; + expect(message).toContain(server.mcpResourceUrl); + expect(message).toContain(advertisedResource); + }), + ), + ); + + it.effect("canonicalizes the RFC 8707 resource before persisting a DCR client", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + const rawResource = `${server.issuerUrl.replace("http://", "HTTP://")}/mcp/?tenant=Acme#fragment`; + const expectedResource = `${server.issuerUrl}/mcp/?tenant=Acme`; + + const slug = yield* executor.oauth.registerDynamicClient({ + owner: "org", + slug: CLIENT, + issuer: server.issuerUrl, + registrationEndpoint: server.registrationEndpoint, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + resource: rawResource, + scopes: ["read"], + tokenEndpointAuthMethodsSupported: ["none"], + clientName: "Executor", + redirectUri: FLOW_REDIRECT_URI, + originIntegration: INTEG, + }); + + const clients = yield* executor.oauth.listClients(); + const minted = clients.find((client) => String(client.slug) === String(slug)); + expect(minted?.resource).toBe(expectedResource); + }), + ), + ); + it.effect("DCR mints + persists a public (no-secret) client that lists + connects", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 65227a0f9c..71bb2690e8 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -50,6 +50,7 @@ import { import type { OwnerBinding } from "./plugin"; import type { CredentialProvider } from "./provider"; import { + canonicalResourceUrl, discoverAuthorizationServerMetadata, discoverProtectedResourceMetadata, OAuthDiscoveryError, @@ -792,6 +793,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { input: RegisterDynamicClientInput, issuer: string | null, flowRedirectUri: string | null, + resource: string | null, ): Effect.Effect< { readonly existingSlug: OAuthClientSlug | null; @@ -801,7 +803,6 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { > => Effect.gen(function* () { const candidates = yield* dcrCandidatesForIssuer(input.owner, issuer); - const resource = input.resource ?? null; // A candidate is reusable only when the callback it registered with the // AS still matches the current flow's callback — strict servers reject an // authorize request whose redirect_uri differs from the registration @@ -859,11 +860,27 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ): Effect.Effect => Effect.gen(function* () { const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint); + const requestedResource = input.resource ?? null; + // Canonicalized ONCE here; reuse-matching, persistence, and every later + // authorize/token request all see this exact value. Rows persisted + // before canonicalization may hold a raw string that no longer matches + // (case, trailing slash) — those clients simply re-register, which is + // safe and self-healing, so no backfill migration is warranted. + const resource = + requestedResource === null + ? null + : yield* Effect.try({ + try: () => canonicalResourceUrl(requestedResource), + catch: () => + new OAuthRegisterDynamicError({ + message: `OAuth resource must be an absolute URL: ${requestedResource}`, + }), + }); // Resolved before the reuse decision: a persisted client registered with // a DIFFERENT callback must not be reused (strict servers 400 the // authorize request), so the reuse lookup compares against this value. const flowRedirectUri = input.redirectUri ?? redirectUri ?? null; - const reuse = yield* decideDcrClientReuse(input, issuer, flowRedirectUri); + const reuse = yield* decideDcrClientReuse(input, issuer, flowRedirectUri, resource); if (reuse.existingSlug !== null) return reuse.existingSlug; const slug = reuse.registrationSlug; @@ -920,7 +937,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { slug, authorizationUrl: input.authorizationUrl, tokenUrl: input.tokenUrl, - resource: input.resource ?? null, + resource, grant: "authorization_code", clientId: information.client_id, clientSecret: information.client_secret ?? "", @@ -1427,7 +1444,13 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const options = { endpointUrlPolicy: deps.endpointUrlPolicy }; // Try protected-resource metadata first (RFC 9728), then the AS issuer. const resource = yield* discoverProtectedResourceMetadata(input.url, options).pipe( - Effect.catch(() => Effect.succeed(null)), + Effect.mapError( + (cause) => + new OAuthProbeError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field + message: `OAuth discovery failed: ${cause.message}`, + }), + ), ); // EXPLICIT discovery order: when the protected-resource metadata advertises // an authorization server, probe that; otherwise probe the input endpoint diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts index 95ef31ec05..aa6b6799ae 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -61,6 +61,10 @@ export interface OAuthTestServerOptions { readonly invalidRefreshTokenErrorCode?: string; readonly idTokenClaims?: Readonly>; readonly refreshIdTokenClaims?: Readonly>; + /** Override the RFC 8414 `issuer` advertised by authorization-server metadata. */ + readonly advertisedIssuer?: string; + /** Override the RFC 9728 `resource` advertised by protected-resource metadata. */ + readonly advertisedResource?: string; /** Gate Dynamic Client Registration on the requested redirect URIs. When set, * `/register` returns `400 invalid_redirect_uri` unless every requested * `redirect_uris` entry is approved. Mirrors authorization servers (e.g. @@ -487,7 +491,7 @@ export const serveOAuthTestServer = ( if (requestUrl.pathname.startsWith("/.well-known/oauth-protected-resource")) { const suffix = requestUrl.pathname.slice("/.well-known/oauth-protected-resource".length); - const resource = `${currentIssuerUrl}${suffix}`; + const resource = options.advertisedResource ?? `${currentIssuerUrl}${suffix}`; return jsonResponse(200, { resource, authorization_servers: [currentIssuerUrl], @@ -501,7 +505,7 @@ export const serveOAuthTestServer = ( requestUrl.pathname === "/.well-known/openid-configuration" ) { return jsonResponse(200, { - issuer: currentIssuerUrl, + issuer: options.advertisedIssuer ?? currentIssuerUrl, authorization_endpoint: `${currentIssuerUrl}/authorize`, token_endpoint: `${currentIssuerUrl}/token`, registration_endpoint: `${currentIssuerUrl}/register`, From b6bb4969a663e6a21ce251b76ba6bb79df482c55 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:06:13 -0700 Subject: [PATCH 004/328] Note in AGENTS.md that e2e is slow to run during development (#1468) --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 515f491af7..244b88678a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ Stable contracts live here. Current setup and server mechanics are in package script. Never use `bun test`. - Use the narrowest meaningful verification while iterating. Merge-ready gates are `bun run format:check`, `lint`, `typecheck`, and `test`. +- e2e is slow. Avoid running it often during development: optimize for + iteration speed first, then test. Save e2e for when the change is settled, + and run the narrowest scenario rather than the full suite. - Run `bun run format` before a PR and include only files owned by the branch. - User-visible work requires a specific e2e run, a browsable dev instance, and the recording or trace showing what to inspect. Add a scenario when none From ce4fdc4aeb9716f04d5bb0e2a151514e3951c3cc Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:27:15 -0700 Subject: [PATCH 005/328] Revert "Validate OAuth discovery metadata identity (RFC 8414 / RFC 9728 / RFC 8707) (#1457)" This reverts commit cbf25cbe3f836e30a7e74c02237fa1ac661191e2. --- bun.lock | 4 +- e2e/package.json | 2 +- e2e/scenarios/oauth-dcr-compliance.test.ts | 328 ------------------ packages/core/sdk/src/oauth-discovery.test.ts | 83 +---- packages/core/sdk/src/oauth-discovery.ts | 39 +-- .../sdk/src/oauth-register-dynamic.test.ts | 69 +--- packages/core/sdk/src/oauth-service.ts | 31 +- .../core/sdk/src/testing/oauth-test-server.ts | 8 +- 8 files changed, 30 insertions(+), 534 deletions(-) delete mode 100644 e2e/scenarios/oauth-dcr-compliance.test.ts diff --git a/bun.lock b/bun.lock index 77798579a1..bc5ca40ea5 100644 --- a/bun.lock +++ b/bun.lock @@ -343,7 +343,7 @@ "version": "0.0.32", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.7", + "@executor-js/emulate": "^0.13.6", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -1741,7 +1741,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.13.7", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-119UmWRSAtagLWdH8a53X/X1/q1YjM4O3Gkp2STy4/DeSNg7CTIF4XV7gNiNTAed90iLfte1vQzTwh1o/O5aWg=="], + "@executor-js/emulate": ["@executor-js/emulate@0.13.6", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-FwR2RvO5DnwYgGO2w3JKPCpUx8o+AzSPJeei/oYMIrb4PYg2bQceucxiw52698t9SDEDwAOHkdC/Vv1Tv1+MJQ=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], diff --git a/e2e/package.json b/e2e/package.json index 1840ee1168..ca937fc190 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.7", + "@executor-js/emulate": "^0.13.6", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/e2e/scenarios/oauth-dcr-compliance.test.ts b/e2e/scenarios/oauth-dcr-compliance.test.ts deleted file mode 100644 index bf4aefb770..0000000000 --- a/e2e/scenarios/oauth-dcr-compliance.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -// Cross-target: DCR (RFC 7591) compliance against a real emulated MCP -// authorization server — the probe → registerDynamic → start → complete -// journey driven through the product's typed API, with the MCP emulator -// seeded into each misbehavior/variation an RFC-compliant client must handle. -// -// Each scenario is one gap from the 2026-07 DCR spec audit: -// 1. RFC 8414 §3.3 — a metadata document whose `issuer` does not match the -// URL it was fetched from must be REJECTED at probe time. -// 2. RFC 9728 §3.3 — protected-resource metadata whose `resource` is not -// the requested identifier must be rejected, never adopted as the -// RFC 8707 token audience. -// 3. RFC 8414 §2 — omitted `token_endpoint_auth_methods_supported` means -// client_secret_basic, NOT `none`; a registration + token exchange -// against such a server must authenticate with HTTP Basic. -// 4. RFC 7591 §3.2.1 — the server may substitute registration metadata; -// the client must honor the RETURNED token_endpoint_auth_method (a -// server answering client_secret_basic gets Basic on /token, even when -// the client asked for `none`). -import { randomBytes } from "node:crypto"; -import { createServer } from "node:net"; - -import { expect } from "@effect/vitest"; -import { Effect, Predicate } from "effect"; -import { composePluginApi } from "@executor-js/api/server"; -import { createEmulator, type Emulator } from "@executor-js/emulate"; -import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; -import type { OAuthProbeError } from "@executor-js/sdk"; -import { - AuthTemplateSlug, - ConnectionName, - IntegrationSlug, - OAuthClientSlug, -} from "@executor-js/sdk/shared"; - -import { scenario } from "../src/scenario"; -import { Api, Target } from "../src/services"; - -const api = composePluginApi([mcpHttpPlugin()] as const); - -const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; - -/** The slug `normalizeMcpAuthMethods` assigns a slug-less oauth2 method. */ -const OAUTH2_TEMPLATE = AuthTemplateSlug.make("oauth2"); - -const availablePort = Effect.callback((resume) => { - const server = createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : 0; - server.close(() => { - resume(Effect.succeed(port)); - }); - }); -}); - -/** A private MCP emulator seeded with `oauth` misbehavior knobs plus one - * authorizable user. Local + in-process so each scenario owns its instance. */ -const mcpEmulator = (oauth: Record) => - Effect.acquireRelease( - Effect.gen(function* () { - const port = yield* availablePort; - const emulator = yield* Effect.promise(() => createEmulator({ service: "mcp", port })); - yield* Effect.promise(() => emulator.seed({ users: [{ login: "octocat" }], oauth })); - return emulator; - }), - (emulator: Emulator) => Effect.promise(() => emulator.close()).pipe(Effect.ignore), - ); - -/** Follow the emulator's consent screen headlessly: GET /authorize renders a - * form per seeded user; POST the approval and capture the redirect back to - * the executor callback. Returns the `code` + `state` from that location. */ -const approveConsent = (authorizationUrl: string) => - Effect.promise(async () => { - const authorize = new URL(authorizationUrl); - const page = await fetch(authorize, { redirect: "manual" }); - if (!page.ok) throw new Error(`authorize page failed: ${page.status}`); - const approval = new URL("/authorize/approve", authorize.origin); - const body = new URLSearchParams({ - client_id: authorize.searchParams.get("client_id") ?? "", - redirect_uri: authorize.searchParams.get("redirect_uri") ?? "", - state: authorize.searchParams.get("state") ?? "", - scope: authorize.searchParams.get("scope") ?? "", - code_challenge: authorize.searchParams.get("code_challenge") ?? "", - code_challenge_method: authorize.searchParams.get("code_challenge_method") ?? "", - resource: authorize.searchParams.get("resource") ?? "", - login: "octocat", - }); - const response = await fetch(approval, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: body.toString(), - redirect: "manual", - }); - const location = response.headers.get("location"); - if (response.status !== 302 || !location) { - throw new Error(`consent approval did not redirect: ${response.status}`); - } - const redirected = new URL(location); - const code = redirected.searchParams.get("code"); - const state = redirected.searchParams.get("state"); - if (!code || !state) throw new Error(`callback carried no code/state: ${location}`); - return { code, state }; - }); - -// --------------------------------------------------------------------------- -// 1. RFC 8414 issuer validation: metadata lying about its issuer is rejected. -// --------------------------------------------------------------------------- - -scenario( - "OAuth DCR · probe rejects authorization-server metadata whose issuer mismatches", - {}, - Effect.scoped( - Effect.gen(function* () { - const target = yield* Target; - const { client: makeClient } = yield* Api; - const identity = yield* target.newIdentity(); - const client = yield* makeClient(api, identity); - const emulator = yield* mcpEmulator({ issuerOverride: "https://evil.example.com" }); - - // The metadata document at /.well-known/... claims an issuer - // it does not live at. RFC 8414 §3.3: such metadata MUST NOT be used. - const failure = yield* Effect.flip( - client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }), - ); - expect(Predicate.isTagged("OAuthProbeError")(failure)).toBe(true); - const probeError = failure as OAuthProbeError; - expect(probeError.message).toContain("issuer"); - expect(probeError.message).toContain("https://evil.example.com"); - }), - ), -); - -// --------------------------------------------------------------------------- -// 2. RFC 9728 resource validation: PRM naming a foreign resource is rejected. -// --------------------------------------------------------------------------- - -scenario( - "OAuth DCR · probe rejects protected-resource metadata naming a foreign resource", - {}, - Effect.scoped( - Effect.gen(function* () { - const target = yield* Target; - const { client: makeClient } = yield* Api; - const identity = yield* target.newIdentity(); - const client = yield* makeClient(api, identity); - const emulator = yield* mcpEmulator({ - resourceOverride: "https://other.example.com/mcp", - }); - - // The PRM's `resource` is not the identifier we asked about. Accepting - // it would make the foreign URL the RFC 8707 token audience. - const failure = yield* Effect.flip( - client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }), - ); - expect(Predicate.isTagged("OAuthProbeError")(failure)).toBe(true); - const probeError = failure as OAuthProbeError; - expect(probeError.message).toContain("resource"); - expect(probeError.message).toContain("https://other.example.com/mcp"); - }), - ), -); - -// --------------------------------------------------------------------------- -// 3. RFC 8414 default auth method: omitted advertisement = client_secret_basic. -// The emulator omits token_endpoint_auth_methods_supported entirely and its -// token endpoint REQUIRES HTTP Basic for basic clients — a client that -// wrongly assumes `none` (or posts the secret in the form body) fails. -// --------------------------------------------------------------------------- - -scenario( - "OAuth DCR · omitted auth-methods metadata negotiates client_secret_basic end to end", - { - skip: "red until DCR auth-method negotiation honors the RFC 8414 client_secret_basic default (needs the oauth_client token_endpoint_auth_method column)", - }, - Effect.scoped( - Effect.gen(function* () { - const target = yield* Target; - const { client: makeClient } = yield* Api; - const identity = yield* target.newIdentity(); - const client = yield* makeClient(api, identity); - const emulator = yield* mcpEmulator({ tokenEndpointAuthMethods: "omit" }); - - const integration = IntegrationSlug.make(unique("dcr-basic")); - yield* client.mcp.addServer({ - payload: { - transport: "remote", - name: "DCR compliance MCP", - endpoint: `${emulator.url}/mcp`, - slug: String(integration), - authenticationTemplate: [{ kind: "oauth2" }], - }, - }); - const probe = yield* client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }); - expect(probe.registrationEndpoint, "the emulator advertises DCR").toBeTruthy(); - // RFC 8414 §2: omitted means client_secret_basic — the probe must not - // fabricate a `none` capability the server never advertised. - expect(probe.tokenEndpointAuthMethodsSupported ?? null).toBeNull(); - - const registered = yield* client.oauth.registerDynamic({ - payload: { - owner: "user", - slug: OAuthClientSlug.make(unique("dcr-basic")), - issuer: probe.issuer ?? null, - registrationEndpoint: probe.registrationEndpoint!, - authorizationUrl: probe.authorizationUrl, - tokenUrl: probe.tokenUrl, - resource: probe.resource ?? null, - scopes: [...(probe.scopesSupported ?? [])], - tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, - clientName: "Executor", - originIntegration: integration, - }, - }); - - const started = yield* client.oauth.start({ - payload: { - client: registered.client, - clientOwner: "user", - owner: "user", - name: ConnectionName.make("main"), - integration, - template: OAUTH2_TEMPLATE, - }, - }); - expect(started.status).toBe("redirect"); - if (started.status !== "redirect") return; - - // The token exchange must authenticate with HTTP Basic (the emulator - // rejects form-body secrets for basic clients) — completing proves the - // negotiated AND executed method is client_secret_basic. - const { code } = yield* approveConsent(started.authorizationUrl); - const connection = yield* client.oauth.complete({ - payload: { state: started.state, code }, - }); - expect(String(connection.name)).toBe("main"); - - // Completion alone is not proof: the emulator honors whatever method the - // client REQUESTED at registration, so a wrongly-negotiated `none` also - // completes (public client + PKCE). The on-wire /token request is the - // authoritative check: a Basic client sends an Authorization header - // (redacted in the ledger, but its presence survives), a `none` client - // sends none at all. - const ledger = yield* Effect.promise(() => emulator.ledger.list()); - const tokenExchange = ledger.find( - (entry) => - entry.method === "POST" && - entry.path.endsWith("/token") && - JSON.stringify(entry.request.body ?? "").includes("authorization_code"), - ); - expect(tokenExchange, "the token exchange is in the ledger").toBeDefined(); - expect( - Object.keys(tokenExchange!.request.headers).map((h) => h.toLowerCase()), - "the token exchange authenticated with HTTP Basic", - ).toContain("authorization"); - }), - ), -); - -// --------------------------------------------------------------------------- -// 4. RFC 7591 metadata substitution: the server upgrades the requested `none` -// to client_secret_basic in the registration RESPONSE; the client must use -// the returned method on /token, not the one it asked for. -// --------------------------------------------------------------------------- - -scenario( - "OAuth DCR · the server-substituted token auth method is honored on the token exchange", - { - skip: "red until the DCR response's token_endpoint_auth_method is persisted and used on /token (needs the oauth_client token_endpoint_auth_method column)", - }, - Effect.scoped( - Effect.gen(function* () { - const target = yield* Target; - const { client: makeClient } = yield* Api; - const identity = yield* target.newIdentity(); - const client = yield* makeClient(api, identity); - const emulator = yield* mcpEmulator({ dcrAuthMethodOverride: "client_secret_basic" }); - - const integration = IntegrationSlug.make(unique("dcr-subst")); - yield* client.mcp.addServer({ - payload: { - transport: "remote", - name: "DCR substitution MCP", - endpoint: `${emulator.url}/mcp`, - slug: String(integration), - authenticationTemplate: [{ kind: "oauth2" }], - }, - }); - const probe = yield* client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }); - - // The server advertises `none` (so the client requests it) but the DCR - // response substitutes client_secret_basic + mints a secret. - const registered = yield* client.oauth.registerDynamic({ - payload: { - owner: "user", - slug: OAuthClientSlug.make(unique("dcr-subst")), - issuer: probe.issuer ?? null, - registrationEndpoint: probe.registrationEndpoint!, - authorizationUrl: probe.authorizationUrl, - tokenUrl: probe.tokenUrl, - resource: probe.resource ?? null, - scopes: [...(probe.scopesSupported ?? [])], - tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, - clientName: "Executor", - originIntegration: integration, - }, - }); - - const started = yield* client.oauth.start({ - payload: { - client: registered.client, - clientOwner: "user", - owner: "user", - name: ConnectionName.make("main"), - integration, - template: OAUTH2_TEMPLATE, - }, - }); - expect(started.status).toBe("redirect"); - if (started.status !== "redirect") return; - - const { code } = yield* approveConsent(started.authorizationUrl); - const connection = yield* client.oauth.complete({ - payload: { state: started.state, code }, - }); - expect(String(connection.name)).toBe("main"); - }), - ), -); diff --git a/packages/core/sdk/src/oauth-discovery.test.ts b/packages/core/sdk/src/oauth-discovery.test.ts index 5badf0b6bc..7e30d19777 100644 --- a/packages/core/sdk/src/oauth-discovery.test.ts +++ b/packages/core/sdk/src/oauth-discovery.test.ts @@ -79,14 +79,14 @@ const withOAuthFixture = ( ); describe("canonicalResourceUrl", () => { - it("lowercases scheme + host, keeps query + trailing slash, and drops fragment", () => { + it("lowercases scheme + host, drops trailing slash, fragment, and query", () => { expect(canonicalResourceUrl("https://API.Example.com/v1/mcp/")).toBe( - "https://api.example.com/v1/mcp/", + "https://api.example.com/v1/mcp", ); expect(canonicalResourceUrl("HTTPS://api.example.com/v1/mcp?x=1#frag")).toBe( - "https://api.example.com/v1/mcp?x=1", + "https://api.example.com/v1/mcp", ); - expect(canonicalResourceUrl("https://api.example.com/")).toBe("https://api.example.com/"); + expect(canonicalResourceUrl("https://api.example.com/")).toBe("https://api.example.com"); }); }); @@ -99,7 +99,7 @@ describe("discoverProtectedResourceMetadata", () => { } if (request.url === "/.well-known/oauth-protected-resource") { return sendJson({ - resource: `${baseUrl}/graphql`, + resource: baseUrl, authorization_servers: [baseUrl], scopes_supported: ["read"], }); @@ -170,22 +170,6 @@ describe("discoverAuthorizationServerMetadata", () => { ), ); - it.effect("treats a trailing slash as the same authorization-server issuer", () => - withOAuthFixture( - (_request, baseUrl) => - sendJson({ - issuer: `${baseUrl}/`, - authorization_endpoint: `${baseUrl}/authorize`, - token_endpoint: `${baseUrl}/token`, - }), - ({ baseUrl }) => - Effect.gen(function* () { - const result = yield* discoverAuthorizationServerMetadata(baseUrl); - expect(result?.metadata.issuer).toBe(`${baseUrl}/`); - }), - ), - ); - it.effect("requires issuer + authorize + token endpoints", () => withOAuthFixture( () => sendJson({ issuer: "http://127.0.0.1" }), @@ -199,9 +183,9 @@ describe("discoverAuthorizationServerMetadata", () => { it.effect("rejects unsupported authorization server endpoint URLs", () => withOAuthFixture( - (_request, baseUrl) => + () => sendJson({ - issuer: baseUrl, + issuer: "http://example.com", authorization_endpoint: "javascript:alert(1)", token_endpoint: "http://example.com/token", response_types_supported: ["code"], @@ -224,9 +208,9 @@ describe("discoverAuthorizationServerMetadata", () => { it.effect("allows HTTP authorization server endpoints when the caller opts into local HTTP", () => withOAuthFixture( - (_request, baseUrl) => + () => sendJson({ - issuer: baseUrl, + issuer: "http://example.com", authorization_endpoint: "http://example.com/authorize", token_endpoint: "http://example.com/token", response_types_supported: ["code"], @@ -345,7 +329,7 @@ describe("beginDynamicAuthorization", () => { } if (request.url === "/.well-known/oauth-protected-resource") { return sendJson({ - resource: `${baseUrl}/graphql/v2`, + resource: baseUrl, authorization_servers: [baseUrl], scopes_supported: ["openid", "profile", "email", "offline_access", "workspace:member"], }); @@ -388,11 +372,11 @@ describe("beginDynamicAuthorization", () => { expect(url.searchParams.get("response_type")).toBe("code"); expect(url.searchParams.get("state")).toBe("state-xyz"); expect(url.searchParams.get("code_challenge_method")).toBe("S256"); - expect(url.searchParams.get("resource")).toBe(`${baseUrl}/graphql/v2`); + expect(url.searchParams.get("resource")).toBe(baseUrl); expect(result.state.authorizationServerMetadata.token_endpoint).toBe( `${baseUrl}/oauth/token`, ); - expect(result.state.resourceMetadata?.resource).toBe(`${baseUrl}/graphql/v2`); + expect(result.state.resourceMetadata?.resource).toBe(baseUrl); }), ), ); @@ -637,7 +621,7 @@ describe("beginDynamicAuthorization", () => { }); const authUrl = new URL(result.authorizationUrl); - expect(authUrl.searchParams.get("resource")).toBe(`${baseUrl}/v1/mcp/`); + expect(authUrl.searchParams.get("resource")).toBe(`${baseUrl}/v1/mcp`); }), ), ); @@ -916,47 +900,6 @@ describe("beginDynamicAuthorization", () => { ), ); - it.effect("rejects an ancestor resource indicator from previous discovery state", () => - withOAuthFixture( - () => notFound(), - ({ baseUrl, requests }) => - Effect.gen(function* () { - const exit = yield* Effect.exit( - beginDynamicAuthorization({ - endpoint: `${baseUrl}/mcp`, - redirectUrl: "https://app/cb", - state: "s", - previousState: { - authorizationServerUrl: baseUrl, - authorizationServerMetadataUrl: `${baseUrl}/.well-known/oauth-authorization-server`, - authorizationServerMetadata: { - issuer: baseUrl, - authorization_endpoint: `${baseUrl}/authorize`, - token_endpoint: `${baseUrl}/token`, - }, - resourceMetadata: null, - clientInformation: { - client_id: "cached-client", - }, - resource: baseUrl, - }, - }), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - const reason = exit.cause.reasons.find(Cause.isFailReason); - expect(reason?.error).toEqual( - expect.objectContaining({ - _tag: "OAuthDiscoveryError", - message: expect.stringMatching(/resource does not match requested endpoint/), - }), - ); - expect((yield* requests).length).toBe(0); - }), - ), - ); - it.effect("rejects servers that don't support PKCE S256", () => withOAuthFixture( (request, baseUrl) => { diff --git a/packages/core/sdk/src/oauth-discovery.ts b/packages/core/sdk/src/oauth-discovery.ts index c6e95b8be3..25786012cf 100644 --- a/packages/core/sdk/src/oauth-discovery.ts +++ b/packages/core/sdk/src/oauth-discovery.ts @@ -162,32 +162,12 @@ const validateEndpointUrl = ( }), }); -/** RFC 8414 issuer identity: scheme, host, port, and path after URL - * normalization; a trailing slash alone does not create a different issuer. - * Deliberately NOT `canonicalResourceUrl`: an issuer never carries a query, - * and its trailing slash is insignificant (RFC 8414 §2), whereas a resource - * identifier's query and trailing slash are part of the identity (RFC 8707). */ -const canonicalIssuer = (value: string): string => { - const url = new URL(value); - const path = url.pathname.replace(/\/+$/, ""); - return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${path}`; -}; - const validateAuthorizationServerMetadata = ( metadata: OAuthAuthorizationServerMetadata, - expectedIssuer: string, policy: OAuthEndpointUrlPolicy = {}, ): Effect.Effect => Effect.gen(function* () { yield* validateEndpointUrl(metadata.issuer, "issuer", policy); - if (canonicalIssuer(metadata.issuer) !== canonicalIssuer(expectedIssuer)) { - return yield* new OAuthDiscoveryError({ - message: - `Authorization server metadata issuer ${metadata.issuer} does not match the requested ` + - `issuer ${expectedIssuer}. Correct the server's issuer metadata or use the matching ` + - `authorization server URL.`, - }); - } yield* validateEndpointUrl(metadata.authorization_endpoint, "authorization_endpoint", policy); yield* validateEndpointUrl(metadata.token_endpoint, "token_endpoint", policy); if (metadata.registration_endpoint) { @@ -321,9 +301,6 @@ export const discoverProtectedResourceMetadata = ( }), ), ); - if (metadata.resource) { - yield* validateResourceIndicator(metadata.resource, canonicalResourceUrl(resourceUrl)); - } return { metadataUrl: url, metadata }; } return null; @@ -417,7 +394,7 @@ export const discoverAuthorizationServerMetadata = ( }), ), ); - yield* validateAuthorizationServerMetadata(metadata, issuer, options.endpointUrlPolicy); + yield* validateAuthorizationServerMetadata(metadata, options.endpointUrlPolicy); return { metadataUrl, metadata }; } return null; @@ -606,15 +583,16 @@ export const registerDynamicClient = ( // // MCP Authorization 2025-06-18 requires `resource` on /authorize and /token // requests. RFC 8707 §2 says the value is "an absolute URI" identifying the -// protected resource. Query components and path trailing slashes are part of -// the identifier; fragments are not sent, and scheme/host compare lowercase. +// protected resource — same scheme + host + (optional) path, no fragment, +// no query, lowercased scheme/host. // --------------------------------------------------------------------------- export const canonicalResourceUrl = (value: string): string => { const url = new URL(value); const scheme = url.protocol.toLowerCase(); const host = url.host.toLowerCase(); - return `${scheme}//${host}${url.pathname}${url.search}`; + const path = url.pathname.replace(/\/+$/, ""); + return `${scheme}//${host}${path}`; }; const validateResourceIndicator = ( @@ -630,14 +608,11 @@ const validateResourceIndicator = ( }), }).pipe( Effect.flatMap((actual) => - actual === expected + actual === expected || expected.startsWith(`${actual}/`) ? Effect.succeed(actual) : Effect.fail( new OAuthDiscoveryError({ - message: - `Protected resource metadata resource does not match requested endpoint: ` + - `advertised ${actual}; requested ${expected}. Correct the server's resource ` + - `metadata or use the matching resource URL.`, + message: "Protected resource metadata resource does not match requested endpoint", cause: { expected, actual }, }), ), diff --git a/packages/core/sdk/src/oauth-register-dynamic.test.ts b/packages/core/sdk/src/oauth-register-dynamic.test.ts index 6f69badbb1..266c7cc4d6 100644 --- a/packages/core/sdk/src/oauth-register-dynamic.test.ts +++ b/packages/core/sdk/src/oauth-register-dynamic.test.ts @@ -8,7 +8,7 @@ import { OAuthClientSlug, ToolName, } from "./ids"; -import { OAuthProbeError, OAuthRegisterDynamicError } from "./oauth-client"; +import { OAuthRegisterDynamicError } from "./oauth-client"; import { definePlugin } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; @@ -58,73 +58,6 @@ const oauthPlugin = definePlugin(() => ({ const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; describe("oauth.registerDynamicClient", () => { - it.effect("probe rejects authorization-server metadata with a mismatched issuer", () => - Effect.scoped( - Effect.gen(function* () { - const advertisedIssuer = "https://issuer.example/mismatched"; - const server = yield* serveOAuthTestServer({ advertisedIssuer }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); - - const error = yield* Effect.flip(executor.oauth.probe({ url: server.mcpResourceUrl })); - expect(error).toBeInstanceOf(OAuthProbeError); - if (!Predicate.isTagged(error, "OAuthProbeError")) return; - // oxlint-disable-next-line executor/no-unknown-error-message -- test: the tag guard narrows to the public OAuthProbeError contract - const message = error.message; - expect(message).toContain(server.issuerUrl); - expect(message).toContain(advertisedIssuer); - }), - ), - ); - - it.effect("probe rejects protected-resource metadata for a different resource", () => - Effect.scoped( - Effect.gen(function* () { - const advertisedResource = "https://resource.example/mismatched"; - const server = yield* serveOAuthTestServer({ advertisedResource }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); - - const error = yield* Effect.flip(executor.oauth.probe({ url: server.mcpResourceUrl })); - expect(error).toBeInstanceOf(OAuthProbeError); - if (!Predicate.isTagged(error, "OAuthProbeError")) return; - // oxlint-disable-next-line executor/no-unknown-error-message -- test: the tag guard narrows to the public OAuthProbeError contract - const message = error.message; - expect(message).toContain(server.mcpResourceUrl); - expect(message).toContain(advertisedResource); - }), - ), - ); - - it.effect("canonicalizes the RFC 8707 resource before persisting a DCR client", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor } = yield* makeTestWorkspaceHarness({ plugins }); - yield* executor.acme.seed(); - const rawResource = `${server.issuerUrl.replace("http://", "HTTP://")}/mcp/?tenant=Acme#fragment`; - const expectedResource = `${server.issuerUrl}/mcp/?tenant=Acme`; - - const slug = yield* executor.oauth.registerDynamicClient({ - owner: "org", - slug: CLIENT, - issuer: server.issuerUrl, - registrationEndpoint: server.registrationEndpoint, - authorizationUrl: server.authorizationEndpoint, - tokenUrl: server.tokenEndpoint, - resource: rawResource, - scopes: ["read"], - tokenEndpointAuthMethodsSupported: ["none"], - clientName: "Executor", - redirectUri: FLOW_REDIRECT_URI, - originIntegration: INTEG, - }); - - const clients = yield* executor.oauth.listClients(); - const minted = clients.find((client) => String(client.slug) === String(slug)); - expect(minted?.resource).toBe(expectedResource); - }), - ), - ); - it.effect("DCR mints + persists a public (no-secret) client that lists + connects", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 71bb2690e8..65227a0f9c 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -50,7 +50,6 @@ import { import type { OwnerBinding } from "./plugin"; import type { CredentialProvider } from "./provider"; import { - canonicalResourceUrl, discoverAuthorizationServerMetadata, discoverProtectedResourceMetadata, OAuthDiscoveryError, @@ -793,7 +792,6 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { input: RegisterDynamicClientInput, issuer: string | null, flowRedirectUri: string | null, - resource: string | null, ): Effect.Effect< { readonly existingSlug: OAuthClientSlug | null; @@ -803,6 +801,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { > => Effect.gen(function* () { const candidates = yield* dcrCandidatesForIssuer(input.owner, issuer); + const resource = input.resource ?? null; // A candidate is reusable only when the callback it registered with the // AS still matches the current flow's callback — strict servers reject an // authorize request whose redirect_uri differs from the registration @@ -860,27 +859,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ): Effect.Effect => Effect.gen(function* () { const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint); - const requestedResource = input.resource ?? null; - // Canonicalized ONCE here; reuse-matching, persistence, and every later - // authorize/token request all see this exact value. Rows persisted - // before canonicalization may hold a raw string that no longer matches - // (case, trailing slash) — those clients simply re-register, which is - // safe and self-healing, so no backfill migration is warranted. - const resource = - requestedResource === null - ? null - : yield* Effect.try({ - try: () => canonicalResourceUrl(requestedResource), - catch: () => - new OAuthRegisterDynamicError({ - message: `OAuth resource must be an absolute URL: ${requestedResource}`, - }), - }); // Resolved before the reuse decision: a persisted client registered with // a DIFFERENT callback must not be reused (strict servers 400 the // authorize request), so the reuse lookup compares against this value. const flowRedirectUri = input.redirectUri ?? redirectUri ?? null; - const reuse = yield* decideDcrClientReuse(input, issuer, flowRedirectUri, resource); + const reuse = yield* decideDcrClientReuse(input, issuer, flowRedirectUri); if (reuse.existingSlug !== null) return reuse.existingSlug; const slug = reuse.registrationSlug; @@ -937,7 +920,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { slug, authorizationUrl: input.authorizationUrl, tokenUrl: input.tokenUrl, - resource, + resource: input.resource ?? null, grant: "authorization_code", clientId: information.client_id, clientSecret: information.client_secret ?? "", @@ -1444,13 +1427,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const options = { endpointUrlPolicy: deps.endpointUrlPolicy }; // Try protected-resource metadata first (RFC 9728), then the AS issuer. const resource = yield* discoverProtectedResourceMetadata(input.url, options).pipe( - Effect.mapError( - (cause) => - new OAuthProbeError({ - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field - message: `OAuth discovery failed: ${cause.message}`, - }), - ), + Effect.catch(() => Effect.succeed(null)), ); // EXPLICIT discovery order: when the protected-resource metadata advertises // an authorization server, probe that; otherwise probe the input endpoint diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts index aa6b6799ae..95ef31ec05 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -61,10 +61,6 @@ export interface OAuthTestServerOptions { readonly invalidRefreshTokenErrorCode?: string; readonly idTokenClaims?: Readonly>; readonly refreshIdTokenClaims?: Readonly>; - /** Override the RFC 8414 `issuer` advertised by authorization-server metadata. */ - readonly advertisedIssuer?: string; - /** Override the RFC 9728 `resource` advertised by protected-resource metadata. */ - readonly advertisedResource?: string; /** Gate Dynamic Client Registration on the requested redirect URIs. When set, * `/register` returns `400 invalid_redirect_uri` unless every requested * `redirect_uris` entry is approved. Mirrors authorization servers (e.g. @@ -491,7 +487,7 @@ export const serveOAuthTestServer = ( if (requestUrl.pathname.startsWith("/.well-known/oauth-protected-resource")) { const suffix = requestUrl.pathname.slice("/.well-known/oauth-protected-resource".length); - const resource = options.advertisedResource ?? `${currentIssuerUrl}${suffix}`; + const resource = `${currentIssuerUrl}${suffix}`; return jsonResponse(200, { resource, authorization_servers: [currentIssuerUrl], @@ -505,7 +501,7 @@ export const serveOAuthTestServer = ( requestUrl.pathname === "/.well-known/openid-configuration" ) { return jsonResponse(200, { - issuer: options.advertisedIssuer ?? currentIssuerUrl, + issuer: currentIssuerUrl, authorization_endpoint: `${currentIssuerUrl}/authorize`, token_endpoint: `${currentIssuerUrl}/token`, registration_endpoint: `${currentIssuerUrl}/register`, From 77b0821ff9bddd6fb419d81a18b9e1af804fdb55 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:37:07 -0700 Subject: [PATCH 006/328] Refresh OAuth tokens when the upstream returns 401 (#1475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refresh OAuth tokens on upstream 401, not just on stored expiry The proactive expiry check is the only thing that has ever triggered a refresh, and expires_at is only the AS's advertised lifetime. Tokens die earlier than that routinely: server-side revocation, an identity provider idle-timeout policy, or an AS that omits expires_in entirely (null expiry, which shouldRefreshToken returns false for forever). Treat an upstream 401 as authoritative: re-mint once and retry. Excludes 403 (authenticated but not permitted) and connections with no refresh token, and keeps the upstream's own failure when the retry also fails. Also drop pooled MCP sessions on 401 — a session is bound to the bearer it was dialled with, so a retained one would serve the dead credential for the full idle window and the refreshed token could never reach the server. Adds tracing to the refresh path, which had no span, log, or metric. * Add an e2e scenario for reactive refresh on upstream 401 Drives a real authorization-code flow against the test AS with long-lived tokens, so the proactive expiry check never fires and the upstream's 401 is the only thing that can trigger a refresh. The upstream then revokes the bearer it was issued; the next call must still succeed. Asserted from the upstream's own request ledger: three calls, the second carrying the revoked token and the third a different one the AS confirms it issued, plus exactly one refresh grant redeemed. Verified fail-before/pass-after: with the fix reverted the scenario fails with connection_rejected / HTTP 401. Also corrects the refresh span comment, which claimed to stamp the AS error code while the attribute stamps the failure kind. --- .changeset/olive-donkeys-smile.md | 5 + e2e/scenarios/oauth-refresh-on-401.test.ts | 326 +++++++++++++++++ packages/core/sdk/src/auth-tool-failure.ts | 20 +- packages/core/sdk/src/executor.ts | 146 ++++++-- packages/core/sdk/src/index.ts | 1 + packages/core/sdk/src/oauth-flow.test.ts | 331 ++++++++++++++++++ packages/core/sdk/src/oauth-helpers.ts | 13 + .../mcp/src/sdk/connection-pool.test.ts | 41 +++ .../plugins/mcp/src/sdk/connection-pool.ts | 7 + 9 files changed, 869 insertions(+), 21 deletions(-) create mode 100644 .changeset/olive-donkeys-smile.md create mode 100644 e2e/scenarios/oauth-refresh-on-401.test.ts diff --git a/.changeset/olive-donkeys-smile.md b/.changeset/olive-donkeys-smile.md new file mode 100644 index 0000000000..731af2261f --- /dev/null +++ b/.changeset/olive-donkeys-smile.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Refresh OAuth tokens when the upstream rejects them with HTTP 401, not only when the stored expiry says they are due. Connections whose authorization server omits `expires_in` can now recover without a manual reconnect, and the refresh path is traced. diff --git a/e2e/scenarios/oauth-refresh-on-401.test.ts b/e2e/scenarios/oauth-refresh-on-401.test.ts new file mode 100644 index 0000000000..ca44fd04b7 --- /dev/null +++ b/e2e/scenarios/oauth-refresh-on-401.test.ts @@ -0,0 +1,326 @@ +// Cross-target: when the upstream rejects a token executor still believes is +// valid, executor re-mints it and retries — the caller never sees the 401. +// +// `expires_at` only ever holds the authorization server's ADVERTISED lifetime. +// A token can stop working well before that: server-side revocation, an +// identity provider idle-timeout policy shorter than the token lifetime, or an +// AS that never advertised an expiry at all. Before the reactive path, the +// proactive expiry check was the only thing that triggered a refresh, so every +// one of those cases surfaced to the user as "re-authenticate" even though the +// stored refresh token would have worked. +// +// The journey: an OpenAPI integration completes a real authorization-code flow +// against a live test AS that issues LONG-LIVED tokens (so the proactive check +// never fires). The upstream then revokes the bearer it was given. The next +// tool call gets a 401, executor re-mints against the AS, retries with the new +// token, and the call succeeds — proven from the upstream's own request ledger, +// which records both the rejected bearer and the accepted one. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +type UpstreamHandle = { + readonly url: string; + /** Every bearer the upstream saw, oldest first — the evidence that a retry + * happened and that it carried a DIFFERENT token than the rejected call. */ + readonly bearers: () => readonly string[]; + /** Stop honouring the bearer(s) seen so far, exactly as a revocation or an + * idle-timeout policy would. Anything minted afterwards still works. */ + readonly revokeSeenBearers: () => void; + readonly close: () => void; +}; + +/** Upstream on 127.0.0.1 that authenticates for real: `GET /issues` is 200 for + * any bearer it has not been told to reject, and 401 for one it has. */ +const serveUpstream = () => + Effect.acquireRelease( + Effect.callback((resume) => { + const seen: string[] = []; + const revoked = new Set(); + const server = createServer((request, response) => { + const bearer = (request.headers.authorization ?? "").replace(/^Bearer\s+/i, ""); + if (request.method === "GET" && (request.url ?? "").startsWith("/issues")) { + seen.push(bearer); + if (revoked.has(bearer)) { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "invalid_token" })); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ issues: [{ id: 1, title: "first" }] })); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + bearers: () => [...seen], + revokeSeenBearers: () => { + for (const bearer of seen) revoked.add(bearer); + }, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +const spec = ( + baseUrl: string, + oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string }, +): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Issues API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/issues": { + get: { + operationId: "listIssues", + summary: "List issues", + security: [{ oauth: ["issues.read"] }], + responses: { "200": { description: "issues" } }, + }, + }, + }, + components: { + securitySchemes: { + oauth: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: { "issues.read": "Read issues" }, + }, + }, + }, + }, + }, + }); + +const invokeByAddressCode = (address: string, args: unknown) => ` +const segments = ${JSON.stringify(address)}.split(".").slice(1); +let node = tools; +for (const segment of segments) node = node[segment]; +const result = await node(${JSON.stringify(args)}); +return JSON.stringify(result); +`; + +type ToolEnvelope = { + readonly ok: boolean; + readonly data?: unknown; + readonly error?: { + readonly code?: string; + readonly message?: string; + readonly status?: number; + }; +}; + +scenario( + "Auth failures · a token the upstream rejects is re-minted and the call retried, so a revoked credential recovers without a reconnect", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const upstream = yield* serveUpstream(); + // LONG-LIVED tokens are the point: the proactive expiry check must never + // fire, so the only thing that can trigger a refresh here is the + // upstream's 401. Refresh support is on, so the re-mint can succeed. + const oauth = yield* serveOAuthTestServer({ + scopes: ["issues.read"], + tokenExpiresInSeconds: 3600, + }); + const slug = unique("refresh401"); + const clientSlug = OAuthClientSlug.make(unique("refresh401c")); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: spec(upstream.url, oauth) }, + slug, + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "oauth", + kind: "oauth2", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: ["issues.read"], + }, + ], + }, + }); + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: clientSlug, + grant: "authorization_code", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + clientId: "test-client", + clientSecret: "test-secret", + originIntegration: IntegrationSlug.make(slug), + }, + }); + + const started = yield* client.oauth.start({ + payload: { + client: clientSlug, + clientOwner: "org", + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(started.status, "oauth.start redirects to the authorization server").toBe( + "redirect", + ); + if (started.status !== "redirect") return yield* Effect.die("no redirect"); + + // Drive the test IdP's consent by hand (authorize → login → code). + const code = yield* Effect.promise(async () => { + const authorize = await fetch(started.authorizationUrl, { redirect: "manual" }); + const loginUrl = authorize.headers.get("location"); + if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`); + const login = await fetch(loginUrl, { + method: "POST", + headers: { + authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, + }, + redirect: "manual", + }); + const callbackUrl = login.headers.get("location"); + if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`); + const minted = new URL(callbackUrl).searchParams.get("code"); + if (!minted) throw new Error("callback carried no authorization code"); + return minted; + }); + yield* client.oauth.complete({ payload: { state: started.state, code } }); + + const tools = yield* client.tools.list({ query: {} }); + const address = tools + .filter((tool) => String(tool.integration) === slug) + .map((tool) => String(tool.address)) + .find((addr) => addr.endsWith("listIssues")); + expect(address, "the listIssues tool is in the catalog").toBeDefined(); + + const session = mcp.session(identity); + const call = (): Effect.Effect => + Effect.gen(function* () { + let called = yield* session.call("execute", { + code: invokeByAddressCode(address!, {}), + }); + // Approval-gated tools pause the execution once per gated call. + let guard = 0; + while (called.text.includes("executionId:") && guard < 10) { + called = yield* session.approvePaused(called.text); + guard += 1; + } + expect( + called.ok, + `the MCP execute call itself completed (got: ${called.text.slice(0, 400)})`, + ).toBe(true); + return JSON.parse(called.text) as ToolEnvelope; + }); + + // Baseline: the connection works, and the upstream records the bearer. + const first = yield* call(); + expect(first.ok, "the first call succeeds on the freshly minted token").toBe(true); + const bearersAfterFirst = upstream.bearers(); + expect(bearersAfterFirst.length, "the upstream saw exactly one call").toBe(1); + + // Revoke it upstream. Executor's stored `expires_at` still says this + // token is good for an hour — the divergence this whole path exists + // for. Nothing in executor's state changes here. + upstream.revokeSeenBearers(); + + const second = yield* call(); + + // THE guarantee: the caller sees a successful call, not a 401 and not + // a re-authenticate wall. Executor absorbed the rejection by + // re-minting and retrying. + expect( + second.ok, + `the call succeeded after the upstream rejected the stored token (got: ${JSON.stringify(second.error ?? {}).slice(0, 400)})`, + ).toBe(true); + + // Proven from the upstream's own ledger: the retry really happened, + // and it carried a DIFFERENT bearer than the one that was rejected. + const bearers = upstream.bearers(); + expect(bearers.length, "the upstream saw the rejected call and the retry (3 total)").toBe( + 3, + ); + const rejected = bearers[1]!; + const retried = bearers[2]!; + expect(rejected, "the second call reused the token the upstream had revoked").toBe( + bearersAfterFirst[0], + ); + expect(retried, "the retry carried a freshly minted token").not.toBe(rejected); + expect( + yield* oauth.acceptsAccessToken(retried), + "the retry's token is one the authorization server actually issued", + ).toBe(true); + + // A refresh grant really was redeemed — not a silent reuse of + // something already cached. + const refreshGrants = (yield* oauth.requests).filter( + (request) => + request.path === "/token" && + request.method === "POST" && + request.body.includes("grant_type=refresh_token"), + ); + expect(refreshGrants.length, "exactly one refresh grant was redeemed").toBe(1); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(slug), + name: ConnectionName.make("main"), + }, + }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/packages/core/sdk/src/auth-tool-failure.ts b/packages/core/sdk/src/auth-tool-failure.ts index 3216fd9a85..6af5f0d617 100644 --- a/packages/core/sdk/src/auth-tool-failure.ts +++ b/packages/core/sdk/src/auth-tool-failure.ts @@ -1,4 +1,4 @@ -import { ToolResult, type ToolError } from "./tool-result"; +import { isToolResult, ToolResult, type ToolError } from "./tool-result"; export type AuthToolFailureCode = | "connection_value_missing" @@ -84,3 +84,21 @@ export const authToolFailure = (input: AuthToolFailureInput): ToolRes }; return ToolResult.fail(error); }; + +/** + * Whether a tool result is the upstream saying "this credential is not valid" + * — an HTTP 401 that every protocol plugin reports as `connection_rejected`. + * + * This is the trigger for a reactive token refresh, so it is deliberately + * exact. `oauth_scope_insufficient` and any 403 are excluded: those mean + * authenticated-but-not-permitted, and re-minting the same grant returns the + * identical answer (a retry loop that burns a refresh-token rotation per call + * and never converges). `oauth_reauth_required` and `oauth_refresh_failed` are + * excluded too — they are raised by the refresh path itself, so retrying on + * them would mean refreshing in response to a failed refresh. + */ +export const isUnauthorizedToolFailure = (value: unknown): boolean => + isToolResult(value) && + !value.ok && + value.error.code === "connection_rejected" && + value.error.status === 401; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 42dee8cd1b..c0a442323d 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -151,6 +151,7 @@ import { } from "./oauth-helpers"; import { connectionIdentifier } from "./connection-name-identifier"; import { annotateToolResultOutcome } from "./tool-result"; +import { isUnauthorizedToolFailure } from "./auth-tool-failure"; const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90; const PLUGIN_STORAGE_CREATE_ROW_BATCH_SIZE = 90; @@ -1500,10 +1501,15 @@ export const createExecutor = b.and(byOwner(owner)(b), b("slug", "=", slug)), }); + /** What drove a refresh: the pre-call expiry check (`proactive`), or an + * upstream 401 on a token we believed was still valid (`reactive`). */ + type RefreshTrigger = "proactive" | "reactive"; + // Perform the actual refresh-token grant and persist the rotated material. const performTokenRefresh = ( row: ConnectionRow, provider: CredentialProvider, + trigger: RefreshTrigger, ): Effect.Effect => Effect.gen(function* () { const owner = row.owner as Owner; @@ -1653,11 +1659,52 @@ export const createExecutor = Effect.annotateCurrentSpan({ "executor.oauth.refresh.outcome": "ok" })), + Effect.tapError((error: StorageFailure | CredentialResolutionError) => + Effect.annotateCurrentSpan({ + "executor.oauth.refresh.outcome": "fail", + "executor.oauth.refresh.error": Predicate.isTagged(error, "CredentialResolutionError") + ? "CredentialResolutionError" + : "StorageFailure", + // Whether the AS's refusal was definitive (RFC 6749 invalid_grant → + // the refresh token itself is dead) or a transient failure the next + // invoke can retry. The split is the actionable half of the signal. + ...(Predicate.isTagged(error, "CredentialResolutionError") + ? { "executor.oauth.refresh.reauth_required": error.reauthRequired === true } + : {}), + }), + ), + Effect.withSpan("executor.oauth.refresh", { + attributes: { + "executor.integration": String(row.integration), + "executor.connection": String(row.name), + // Which path drove this refresh: the expiry check ahead of a call, + // or an upstream 401 on a token we believed was still good. The + // ratio is the health signal — a rising `reactive` share means + // tokens are dying earlier than their advertised expiry. + "executor.oauth.refresh.trigger": trigger, + }, + }), + ); const refreshConnectionToken = ( row: ConnectionRow, provider: CredentialProvider, + trigger: RefreshTrigger = "proactive", ): Effect.Effect => // Share a single refresh per connection so concurrent resolves of the same // connection all await one refresh-token grant (the AS rotates the refresh @@ -1666,11 +1713,16 @@ export const createExecutor = refreshInFlight.delete(key))), ); @@ -1720,6 +1772,29 @@ export const createExecutor = | null, + StorageFailure | CredentialResolutionError + > => + Effect.gen(function* () { + if (row.oauth_client == null || row.refresh_item_id == null) return null; + const provider = credentialProviders.get(row.provider); + if (!provider) return null; + const access = yield* refreshConnectionToken(row, provider, "reactive"); + return { [PRIMARY_INPUT_VARIABLE]: access }; + }); + /** The primary (`token`) value — the public seam for OAuth + single-input * callers that only ever need one value. */ const resolveConnectionValue = ( @@ -3798,27 +3873,58 @@ export const createExecutor = , + ): Effect.Effect => { + const credential: ToolInvocationCredential = { + owner: parsed.owner, + integration: parsed.integration, + connection: parsed.connection, + template: AuthTemplateSlug.make(connectionRow.template), + value: resolved[PRIMARY_INPUT_VARIABLE] ?? null, + values: resolved, + config: integrationRow ? decodeJsonColumn(integrationRow.config) : undefined, + ...(grantedScopes ? { grantedScopes } : {}), + }; + return wrapInvocationError( + invokeTool({ + ctx: runtime.ctx, + toolRow: row, + credential, + args, + elicit: buildElicit(address, args, handler), + invokeOptions: options, + }), + ); }; - return yield* wrapInvocationError( - runtime.plugin.invokeTool({ - ctx: runtime.ctx, - toolRow: row, - credential, - args, - elicit: buildElicit(address, args, handler), - invokeOptions: options, - }), + const first = yield* invokeWith(values); + // Reactive refresh. `expires_at` is only ever the AS's ADVERTISED + // lifetime; the upstream rejecting the token is the authoritative word + // on whether it is still good. The two diverge routinely: server-side + // revocation, an identity provider's idle-timeout policy shorter than + // the token lifetime, and connections whose AS omitted `expires_in` + // entirely (null expiry → the proactive check never fires, so this is + // their ONLY route back to a working token short of a reconnect). + // + // Deliberately narrow: exactly one retry, only on the 401 that means + // "this credential is not valid", and only for a connection holding a + // refresh token. A 403 is excluded — it means authenticated-but-not- + // permitted, and re-minting the same grant returns the same answer. + // If the retry also fails its result stands, so a genuinely dead grant + // still surfaces the upstream's own auth failure and its reconnect + // guidance rather than a masked one. + if (!isUnauthorizedToolFailure(first)) return first; + const refreshed = yield* forceRefreshConnectionValues(connectionRow).pipe( + // A failed re-mint is not this call's failure to report: the upstream + // already produced an auth failure with recovery guidance, which is + // strictly more actionable than a refresh-plumbing error. Keep it. + Effect.catchTag("CredentialResolutionError", () => Effect.succeed(null)), ); + if (!refreshed) return first; + yield* Effect.annotateCurrentSpan({ "executor.oauth.refresh.retried": true }); + return yield* invokeWith(refreshed); }).pipe( // Expected tool failures (`ToolResult.fail`) resolve through the // success channel, so the tracer alone would record them as healthy diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 0d04e3f390..5750b2f89c 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -412,6 +412,7 @@ export { } from "./sqlite-oauth-client-gc-migration"; export { authToolFailure, + isUnauthorizedToolFailure, type AuthToolFailureCode, type AuthToolFailureInput, } from "./auth-tool-failure"; diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index 8869b81ab5..e2b516d47c 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -10,11 +10,13 @@ import { ToolAddress, ToolName, } from "./ids"; +import { authToolFailure } from "./auth-tool-failure"; import { decodeOAuthCallbackState } from "./oauth"; import { OAuthStartError } from "./oauth-client"; import { missingGrantedOAuthScopes } from "./oauth-service"; import { definePlugin } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; +import { ToolResult } from "./tool-result"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; // Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow @@ -1220,3 +1222,332 @@ describe("missingGrantedOAuthScopes canonicalization", () => { expect(missing).toEqual([]); }); }); + +// --------------------------------------------------------------------------- +// Reactive refresh: an upstream 401 re-mints the access token and retries once. +// +// `expires_at` is only ever the authorization server's ADVERTISED lifetime. The +// upstream rejecting the token is the authoritative word on whether it is still +// good, and the two diverge routinely: server-side revocation, an identity +// provider's idle-timeout policy shorter than the token lifetime, and the case +// these tests pin hardest — an AS that omits `expires_in` entirely, leaving a +// null expiry that the proactive check can never fire on. +// --------------------------------------------------------------------------- + +/** A plugin whose tool authenticates for real: any token in `revoked` gets the + * same `connection_rejected` / HTTP 401 shape every protocol plugin emits; + * anything else succeeds. Modelling revocation (rather than "only the newest + * token works") is what lets a test assert that the RETRY succeeded — the + * re-minted token is simply one the upstream never revoked. */ +const makeRejectingPlugin = (state: { + revoked: Set; + /** Reject every token, even a freshly minted one — a dead grant. */ + rejectEverything?: boolean; + calls: string[]; +}) => + definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }] }), + describeAuthMethods: () => [ + { + id: "oauth", + label: "OAuth2", + kind: "oauth" as const, + template: String(TEMPLATE), + oauth: { scopes: [] }, + }, + ], + invokeTool: ({ credential }) => { + const token = credential.value; + state.calls.push(String(token)); + if (token !== null && !state.rejectEverything && !state.revoked.has(token)) { + return Effect.succeed(ToolResult.ok({ token })); + } + return Effect.succeed( + authToolFailure({ + code: "connection_rejected", + status: 401, + message: "Upstream rejected credentials with HTTP 401.", + integration: { id: String(credential.integration) }, + credential: { kind: "upstream", label: String(credential.connection) }, + }), + ); + }, + extension: (ctx) => ({ + seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: {} }), + }), + }))(); + +/** Mint a connection against `server` and return the harness + call log. */ +const connectRejecting = (options?: { readonly tokenExpiresInSeconds?: number }) => + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ + scopes: ["read"], + ...(options?.tokenExpiresInSeconds !== undefined + ? { tokenExpiresInSeconds: options.tokenExpiresInSeconds } + : {}), + }); + const state = { + revoked: new Set(), + rejectEverything: false, + calls: [] as string[], + }; + const issuedLatest = Effect.gen(function* () { + const issued = yield* server.issuedAccessTokens; + return issued.length > 0 ? issued[issued.length - 1]! : null; + }); + const harness = yield* makeTestWorkspaceHarness({ + plugins: [memoryCredentialsPlugin(), makeRejectingPlugin(state)] as const, + }); + const { executor, config } = harness; + yield* executor.acme.seed(); + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + }); + if (started.status !== "redirect") { + return yield* Effect.die("expected a redirect-status OAuth start"); + } + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + return { server, state, executor, config, issuedLatest }; + }); + +const ADDRESS = ToolAddress.make("tools.acme.org.mine.whoami"); + +/** Mark every token the AS has issued so far as no longer honoured upstream — + * the state a revocation or an idle-timeout policy leaves behind. */ +const revokeIssuedSoFar = ( + server: { readonly issuedAccessTokens: Effect.Effect }, + state: { revoked: Set }, +) => + Effect.gen(function* () { + for (const token of yield* server.issuedAccessTokens) state.revoked.add(token); + }); + +const refreshGrants = (requests: readonly { readonly path: string; readonly body: string }[]) => + requests.filter((r) => r.path === "/token" && r.body.includes("grant_type=refresh_token")); + +describe("reactive OAuth refresh on upstream 401", () => { + it.effect( + "re-mints and retries once when the upstream rejects a token it still believes valid", + () => + Effect.scoped( + Effect.gen(function* () { + const { server, state, executor, issuedLatest } = yield* connectRejecting(); + + // Simulate the divergence: the AS says the token is good for an hour, + // but the upstream has already stopped honouring it (revoked / idle + // timeout). Nothing about `expires_at` reflects this. + yield* revokeIssuedSoFar(server, state); + yield* server.clearRequests; + + const result = (yield* executor.execute(ADDRESS, {})) as { + data: { token: string }; + }; + + // The retry ran against a token the upstream would accept, so the call + // succeeded rather than surfacing the 401 to the user. + const latest = yield* issuedLatest; + expect(result.data.token).toBe(latest); + expect(state.calls).toHaveLength(2); + expect(state.calls[0]).not.toBe(state.calls[1]); + + // Exactly one refresh grant — the retry is single-shot, not a loop. + expect(refreshGrants(yield* server.requests)).toHaveLength(1); + }), + ), + ); + + it.effect("recovers a null-expiry connection, which the proactive check can never fire on", () => + Effect.scoped( + Effect.gen(function* () { + const { server, state, executor, config, issuedLatest } = yield* connectRejecting(); + + // An AS that omits `expires_in` leaves expires_at null. `shouldRefreshToken` + // returns false for null forever, so before the reactive path these + // connections could never refresh at all — they died silently and only a + // manual reconnect brought them back. 5 such rows existed in production. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "mine"), + set: { expires_at: null }, + }), + ); + yield* revokeIssuedSoFar(server, state); + yield* server.clearRequests; + + const result = (yield* executor.execute(ADDRESS, {})) as { + data: { token: string }; + }; + + expect(result.data.token).toBe(yield* issuedLatest); + expect(refreshGrants(yield* server.requests)).toHaveLength(1); + }), + ), + ); + + it.effect("persists the re-minted token so the next call needs no second refresh", () => + Effect.scoped( + Effect.gen(function* () { + const { server, state, executor, issuedLatest } = yield* connectRejecting(); + yield* revokeIssuedSoFar(server, state); + yield* executor.execute(ADDRESS, {}); + + // The re-minted token is not revoked, so a second call must reuse the + // stored value rather than refreshing again. + const current = yield* issuedLatest; + yield* server.clearRequests; + + const result = (yield* executor.execute(ADDRESS, {})) as { + data: { token: string }; + }; + expect(result.data.token).toBe(current); + expect(refreshGrants(yield* server.requests)).toHaveLength(0); + }), + ), + ); + + it.effect("surfaces the upstream's own 401 when the re-minted token is also rejected", () => + Effect.scoped( + Effect.gen(function* () { + const { server, state, executor } = yield* connectRejecting(); + + // The upstream rejects everything, including whatever the refresh mints + // — a genuinely dead grant, not a stale token. The user must see the + // upstream's auth failure and its reconnect guidance, NOT a masked + // error or a retry loop. + state.rejectEverything = true; + yield* server.clearRequests; + + const result = yield* executor.execute(ADDRESS, {}); + + expect(result).toMatchObject({ + ok: false, + error: { code: "connection_rejected", status: 401 }, + }); + // Tried twice, refreshed once, then stopped. + expect(state.calls).toHaveLength(2); + expect(refreshGrants(yield* server.requests)).toHaveLength(1); + }), + ), + ); + + it.effect("does not retry a 403 — re-minting the same grant returns the same answer", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const calls: string[] = []; + const forbiddenPlugin = definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }] }), + describeAuthMethods: () => [ + { + id: "oauth", + label: "OAuth2", + kind: "oauth" as const, + template: String(TEMPLATE), + oauth: { scopes: [] }, + }, + ], + invokeTool: ({ credential }) => { + calls.push(String(credential.value)); + return Effect.succeed( + authToolFailure({ + code: "connection_rejected", + status: 403, + message: "Upstream rejected credentials with HTTP 403.", + integration: { id: String(credential.integration) }, + credential: { kind: "upstream", label: String(credential.connection) }, + }), + ); + }, + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: {} }), + }), + }))(); + + const { executor } = yield* makeTestWorkspaceHarness({ + plugins: [memoryCredentialsPlugin(), forbiddenPlugin] as const, + }); + yield* executor.acme.seed(); + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + }); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + yield* server.clearRequests; + + const result = yield* executor.execute(ADDRESS, {}); + + // A 403 is authenticated-but-not-permitted. Retrying would burn a + // refresh-token rotation per call and never converge. + expect(result).toMatchObject({ ok: false, error: { status: 403 } }); + expect(calls).toHaveLength(1); + expect(refreshGrants(yield* server.requests)).toHaveLength(0); + }), + ), + ); + + it.effect("does not retry when the connection holds no refresh token", () => + Effect.scoped( + Effect.gen(function* () { + const { server, state, executor, config } = yield* connectRejecting(); + + // Nothing to re-mint from: the only recovery is a human reconnect, so + // the upstream's failure must reach the caller on the first try. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "mine"), + set: { refresh_item_id: null }, + }), + ); + yield* revokeIssuedSoFar(server, state); + yield* server.clearRequests; + + const result = yield* executor.execute(ADDRESS, {}); + + expect(result).toMatchObject({ ok: false, error: { status: 401 } }); + expect(state.calls).toHaveLength(1); + expect(refreshGrants(yield* server.requests)).toHaveLength(0); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index daf5892cd8..6ce921bcd2 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -746,6 +746,19 @@ export const refreshAccessToken = ( // Refresh-needed predicate // --------------------------------------------------------------------------- +/** Whether the stored access token is close enough to its expiry to be + * re-minted BEFORE the next call goes out (the proactive path). + * + * A null `expiresAt` means the authorization server never told us when the + * token dies (`expires_in` omitted from the token response). That is not the + * same as "never expires": the token may well be revoked or time out + * upstream. We deliberately do NOT refresh on every call for those — that + * would hammer the AS on connections whose tokens are genuinely long-lived, + * and there is no expiry to be "close to". Instead the reactive path owns + * them: an upstream 401 is the only truthful signal that an unknown-expiry + * token is dead, and `executor.execute` re-mints and retries once on that + * signal. Keep the two paths in sync — narrowing the reactive retry strands + * every null-expiry connection with no way to recover short of a reconnect. */ export const shouldRefreshToken = (input: { readonly expiresAt: number | null; readonly now?: number; diff --git a/packages/plugins/mcp/src/sdk/connection-pool.test.ts b/packages/plugins/mcp/src/sdk/connection-pool.test.ts index 64bb0a4ebc..5efb84b083 100644 --- a/packages/plugins/mcp/src/sdk/connection-pool.test.ts +++ b/packages/plugins/mcp/src/sdk/connection-pool.test.ts @@ -153,4 +153,45 @@ describe("MCP connection pool", () => { }), ), ); + + it.effect("drops a pooled session whose bearer the server has stopped accepting", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMcpServer(() => makeEchoMcpServer()); + const pool = createMcpConnectionPool(); + + yield* invoke({ + endpoint: server.endpoint, + pool, + toolName: "echo", + args: { value: "before" }, + }); + + // A 401 means the bearer this session was dialled with is no longer + // accepted. The session is bound to that token for its lifetime, so + // retaining it would serve the same dead credential for the full idle + // window — and would defeat core's post-401 refresh, whose new token can + // only reach the server over a NEW session. + yield* server.rejectNextSessionRequest(401); + yield* invoke({ + endpoint: server.endpoint, + pool, + toolName: "echo", + args: { value: "unauthorized" }, + }).pipe(Effect.flip); + + const after = yield* invoke({ + endpoint: server.endpoint, + pool, + toolName: "echo", + args: { value: "after" }, + }); + + expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] }); + // A second session was dialled rather than the dead one being reused. + expect(server.sessionCount()).toBe(2); + yield* pool.close(); + }), + ), + ); }); diff --git a/packages/plugins/mcp/src/sdk/connection-pool.ts b/packages/plugins/mcp/src/sdk/connection-pool.ts index 63f6365515..6b732caf78 100644 --- a/packages/plugins/mcp/src/sdk/connection-pool.ts +++ b/packages/plugins/mcp/src/sdk/connection-pool.ts @@ -27,6 +27,13 @@ const isDeadConnectionFailure = (error: unknown): boolean => { return ( error.transportFailure === true || error.status === 400 || + // A 401 means the bearer this session was dialled with is no longer + // accepted. The session is bound to that token for its lifetime, so + // retaining it would hand the same dead credential to every caller for the + // full idle window — and would defeat core's post-401 token refresh, whose + // freshly minted token can only reach the server over a NEW session. Drop + // it so the retry dials with the new credential. + error.status === 401 || error.status === 404 || error.status === 408 ); From 75f16edd319813dbd2bc7b0eb1b3edab32ba3210 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:22:11 -0700 Subject: [PATCH 007/328] Back the JWKS cache with the Workers edge cache (#1479) --- apps/cloud/src/auth/jwks-cache.node.test.ts | 145 +++++++++++++++++++- apps/cloud/src/auth/jwks-cache.ts | 131 +++++++++++++++--- 2 files changed, 255 insertions(+), 21 deletions(-) diff --git a/apps/cloud/src/auth/jwks-cache.node.test.ts b/apps/cloud/src/auth/jwks-cache.node.test.ts index d1f2b47c4b..3d093533e6 100644 --- a/apps/cloud/src/auth/jwks-cache.node.test.ts +++ b/apps/cloud/src/auth/jwks-cache.node.test.ts @@ -9,7 +9,7 @@ import { type KeyLike, } from "jose"; -import { createCachedRemoteJWKSet } from "./jwks-cache"; +import { createCachedRemoteJWKSet, type JwksEdgeCache } from "./jwks-cache"; const issuer = "https://test-authkit.example.com"; const audience = "client_test_fixture"; @@ -169,3 +169,146 @@ describe("createCachedRemoteJWKSet", () => { expect(harness.callCount()).toBe(2); }); }); + +interface EdgeCacheHarness { + readonly cache: JwksEdgeCache; + readonly matchCount: () => number; + readonly putCount: () => number; + readonly stored: () => Response | null; + readonly seed: (keys: ReadonlyArray) => void; +} + +const makeEdgeCacheHarness = (): EdgeCacheHarness => { + let stored: Response | null = null; + let matches = 0; + let puts = 0; + + return { + cache: { + match: async () => { + matches++; + return stored ? stored.clone() : undefined; + }, + put: async (_url, response) => { + puts++; + stored = response; + }, + }, + matchCount: () => matches, + putCount: () => puts, + stored: () => stored, + seed: (keys) => { + const body: JSONWebKeySet = { keys: keys.map((k) => ({ ...k })) }; + stored = new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }); + }, + }; +}; + +describe("createCachedRemoteJWKSet edge cache", () => { + it("FAILING-WITHOUT-EDGE-CACHE: a cold isolate with a warm edge cache never fetches upstream", async () => { + const kp = await generateRotatableKeypair("k1"); + const upstream = makeFetchHarness([kp.publicJwk]); + const edge = makeEdgeCacheHarness(); + edge.seed([kp.publicJwk]); + + // Fresh resolver = fresh isolate: empty in-memory cache. + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: upstream.fetch, + edgeCache: edge.cache, + }); + + const token = await sign(kp); + const { payload } = await jwtVerify(token, jwks, { issuer, audience }); + expect(payload.sub).toBe("user_test"); + expect(edge.matchCount()).toBe(1); + expect(upstream.callCount()).toBe(0); + }); + + it("an edge-cache miss falls through to upstream and populates the edge copy", async () => { + const kp = await generateRotatableKeypair("k1"); + const upstream = makeFetchHarness([kp.publicJwk]); + const edge = makeEdgeCacheHarness(); + + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: upstream.fetch, + edgeCache: edge.cache, + }); + + const token = await sign(kp); + await jwtVerify(token, jwks, { issuer, audience }); + expect(upstream.callCount()).toBe(1); + expect(edge.putCount()).toBe(1); + + // A second cold isolate now reads the populated edge copy. + const jwks2 = createCachedRemoteJWKSet(jwksUrl, { + fetch: upstream.fetch, + edgeCache: edge.cache, + }); + await jwtVerify(token, jwks2, { issuer, audience }); + expect(upstream.callCount()).toBe(1); + }); + + it("a malformed edge-cache entry is a miss, not a failure", async () => { + const kp = await generateRotatableKeypair("k1"); + const upstream = makeFetchHarness([kp.publicJwk]); + const edge = makeEdgeCacheHarness(); + await edge.cache.put(jwksUrl.toString(), new Response("not json")); + + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: upstream.fetch, + edgeCache: edge.cache, + }); + + const token = await sign(kp); + const { payload } = await jwtVerify(token, jwks, { issuer, audience }); + expect(payload.sub).toBe("user_test"); + expect(upstream.callCount()).toBe(1); + }); + + it("key rotation bypasses the stale edge copy and overwrites it", async () => { + const oldKey = await generateRotatableKeypair("k_old"); + const newKey = await generateRotatableKeypair("k_new"); + const upstream = makeFetchHarness([newKey.publicJwk]); + const edge = makeEdgeCacheHarness(); + // The edge copy predates the rotation: it only has the old key. + edge.seed([oldKey.publicJwk]); + + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: upstream.fetch, + edgeCache: edge.cache, + }); + + // First verify warms in-memory from the (stale) edge copy; the new-key + // token then misses, which must force an UPSTREAM refetch — re-reading + // the same stale edge copy would loop the miss forever. + const token = await sign(newKey); + const { payload } = await jwtVerify(token, jwks, { issuer, audience }); + expect(payload.sub).toBe("user_test"); + expect(upstream.callCount()).toBe(1); + // And the rotated set replaced the stale edge copy for other isolates. + expect(edge.putCount()).toBe(1); + }); + + it("degrades to upstream-only when the edge cache itself fails", async () => { + const kp = await generateRotatableKeypair("k1"); + const upstream = makeFetchHarness([kp.publicJwk]); + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fakes the Cache API's own failure mode, a rejected promise + const rejectingCacheCall = () => Promise.reject(new TypeError("cache backend unavailable")); + const broken: JwksEdgeCache = { + match: rejectingCacheCall, + put: rejectingCacheCall, + }; + + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: upstream.fetch, + edgeCache: broken, + }); + + const token = await sign(kp); + const { payload } = await jwtVerify(token, jwks, { issuer, audience }); + expect(payload.sub).toBe("user_test"); + expect(upstream.callCount()).toBe(1); + }); +}); diff --git a/apps/cloud/src/auth/jwks-cache.ts b/apps/cloud/src/auth/jwks-cache.ts index d04e80187c..d3cc87efa3 100644 --- a/apps/cloud/src/auth/jwks-cache.ts +++ b/apps/cloud/src/auth/jwks-cache.ts @@ -11,10 +11,18 @@ // // * Caches the JSON Web Key Set in module-scope memory for a configurable // TTL (default 1 hour). +// * Backs the in-memory layer with the Workers Cache API (`caches.default`) +// so a COLD isolate reads the JWKS from its datacenter's shared cache +// instead of round-tripping to the upstream endpoint. The in-memory layer +// alone left every fresh isolate paying that full fetch, which is exactly +// when users notice: an app-open burst fans out across many new isolates +// and each one stalled ~1-2s inside session verification. // * Single-flights concurrent fetches so a stampede of verifies during a // cache miss only fires one upstream request. // * Force-refreshes once when verification fails with a cached key, so -// genuine key rotation isn't blocked by the TTL. +// genuine key rotation isn't blocked by the TTL. A forced refresh also +// bypasses the shared edge copy (it is just as stale as ours) and +// overwrites it with the freshly fetched set. // // The returned function is a `JWTVerifyGetKey` and slots directly into // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can @@ -32,6 +40,21 @@ import { import { Schema } from "effect"; import { JWKSNoMatchingKey } from "jose/errors"; +/** The slice of the Workers Cache API this module uses, expressed + * structurally: `Cache` from workers-types satisfies it, and node tests can + * supply a conforming fake without the full `Cache` surface. */ +export interface JwksEdgeCache { + match(url: string): Promise; + put(url: string, response: Response): Promise; +} + +// The `caches` global exists in the Workers runtime but not under node (the +// test environment). Typed structurally (the ASSETS precedent in +// env-augment.d.ts) rather than via workers-types: its `Cache` type drags in +// a second `Response` that intersects with the ambient one and rejects +// responses this module constructs. +declare const caches: { readonly default: JwksEdgeCache } | undefined; + export interface CachedRemoteJWKSetOptions { /** * How long a successful fetch is considered fresh. Defaults to 1 hour — @@ -43,6 +66,12 @@ export interface CachedRemoteJWKSetOptions { readonly fetch?: typeof globalThis.fetch; /** HTTP request timeout. Defaults to 5s, matching jose. */ readonly timeoutMs?: number; + /** + * Datacenter-shared cache behind the in-memory layer. Defaults to + * `caches.default` when the runtime provides it (Workers) and to disabled + * when it does not (node tests). Pass explicitly to fake it in tests. + */ + readonly edgeCache?: JwksEdgeCache | null; } export interface CachedRemoteJWKSet extends JWTVerifyGetKey { @@ -122,31 +151,92 @@ export const createCachedRemoteJWKSet = ( // (tests do this) without us snapshotting a stale reference. const fetchImpl = (): typeof globalThis.fetch => options.fetch ?? globalThis.fetch.bind(globalThis); + // `undefined` = not specified, use the runtime default; `null` = disabled. + // Node (tests) has no `caches` global, so the default degrades to disabled + // there without configuration. + const edgeCache = + options.edgeCache !== undefined + ? options.edgeCache + : typeof caches !== "undefined" + ? caches.default + : null; - let entry: CacheEntry | null = null; - let inflight: Promise | null = null; - - const refresh = (): Promise => { - if (inflight) return inflight; - inflight = (async () => { - const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); - const next: CacheEntry = { - jwks, - fetchedAt: Date.now(), - resolver: createLocalJWKSet(jwks), - }; - entry = next; - return next; - })().finally(() => { - inflight = null; + // The edge cache is best-effort: a failed read is a miss, a failed write is + // dropped. Verification correctness never depends on it — the upstream + // fetch remains the source of truth. + const readEdgeCache = async (): Promise => { + if (!edgeCache) return null; + // oxlint-disable-next-line executor/no-promise-catch -- boundary: Cache API failure degrades to a cache miss inside a jose Promise resolver + const cached = await edgeCache.match(url.toString()).catch(() => undefined); + if (!cached) return null; + // oxlint-disable-next-line executor/no-promise-catch -- boundary: a non-JSON cached body degrades to a cache miss inside a jose Promise resolver + const body: unknown = await cached.json().catch(() => null); + if (body === null) return null; + return decodeJsonWebKeySetPayload(body).then( + () => body as JSONWebKeySet, + () => null, + ); + }; + + const writeEdgeCache = async (jwks: JSONWebKeySet): Promise => { + if (!edgeCache) return; + // The Cache API owns expiry: `match` stops returning the entry once + // `max-age` elapses, so the shared copy has the same freshness window as + // the in-memory layer. + const response = new Response(JSON.stringify(jwks), { + headers: { + "content-type": "application/json", + "cache-control": `public, max-age=${Math.floor(ttlMs / 1000)}`, + }, }); - return inflight; + // oxlint-disable-next-line executor/no-promise-catch -- boundary: a failed shared-cache write is dropped; the fetched JWKS is already in hand + await edgeCache.put(url.toString(), response).catch(() => undefined); + }; + + const fetchUpstream = async (): Promise => { + const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); + await writeEdgeCache(jwks); + return jwks; + }; + + let entry: CacheEntry | null = null; + let inflight: { promise: Promise; bypassedEdge: boolean } | null = null; + // Set by `forceRefresh()`: the shared edge copy is as suspect as our + // in-memory one (same staleness), so the next refresh must go upstream and + // overwrite it rather than read it back. + let bypassEdgeOnNextRefresh = false; + + const refresh = (bypassEdge: boolean): Promise => { + const effectiveBypass = bypassEdge || bypassEdgeOnNextRefresh; + // Join an inflight refresh only when it satisfies this request: a + // bypassing caller must not receive the result of an edge-cache read. + if (inflight && (inflight.bypassedEdge || !effectiveBypass)) return inflight.promise; + bypassEdgeOnNextRefresh = false; + const record: { promise: Promise; bypassedEdge: boolean } = { + bypassedEdge: effectiveBypass, + promise: (async () => { + const jwks = effectiveBypass + ? await fetchUpstream() + : ((await readEdgeCache()) ?? (await fetchUpstream())); + const next: CacheEntry = { + jwks, + fetchedAt: Date.now(), + resolver: createLocalJWKSet(jwks), + }; + entry = next; + return next; + })().finally(() => { + if (inflight === record) inflight = null; + }), + }; + inflight = record; + return record.promise; }; const ensureFresh = async (forceRefresh: boolean): Promise => { - if (forceRefresh) return refresh(); + if (forceRefresh) return refresh(true); if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry; - return refresh(); + return refresh(false); }; const get: JWTVerifyGetKey = async (protectedHeader, token) => { @@ -171,6 +261,7 @@ export const createCachedRemoteJWKSet = ( Object.defineProperty(result, "forceRefresh", { value: () => { entry = null; + bypassEdgeOnNextRefresh = true; }, }); Object.defineProperty(result, "inspect", { From 167d899162794064eeac0a755697c2c943f1b9ac Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:22:29 -0700 Subject: [PATCH 008/328] Remove the custom apps plugin (it'll come back but better) (#1476) * Remove the custom apps plugin * Keep packing workerd and worker-bundler in binaries * Remove remaining custom-tools e2e suites * Make self-host worker deps production dependencies --- .changeset/remove-apps-plugin.md | 7 + apps/cli/package.json | 2 + apps/cli/scripts/smoke-packed-apps.ts | 192 ----- apps/cli/src/build.ts | 11 +- apps/cloud/executor.config.ts | 95 +- apps/cloud/package.json | 1 - .../scripts/assert-worker-bundler-assets.mjs | 54 -- apps/cloud/scripts/build.mjs | 3 +- apps/cloud/src/engine/execution-stack.ts | 6 - apps/cloud/src/worker-bundler-artifact.d.ts | 6 - apps/cloud/vite.config.ts | 2 - apps/cloud/wrangler.jsonc | 3 +- apps/host-selfhost/executor.config.ts | 14 +- apps/host-selfhost/package.json | 6 +- apps/host-selfhost/scripts/package-runtime.ts | 3 +- apps/host-selfhost/src/boot.test.ts | 108 +-- apps/local/executor.config.ts | 11 - apps/local/package.json | 1 - bun.lock | 51 +- e2e/cli/custom-tools-packed.test.ts | 264 ------ e2e/desktop-vm/custom-tools-sidecar.test.ts | 228 ----- .../custom-tools-git/custom-tools-shas.txt | 3 - .../custom-tools-git/custom-tools-v1.pack | Bin 1235 -> 0 bytes .../custom-tools-git/custom-tools-v2.pack | Bin 1575 -> 0 bytes .../custom-tools-git/custom-tools-v3.pack | Bin 2064 -> 0 bytes e2e/fixtures/custom-tools-git/generate.ts | 158 ---- .../custom-tools-directory-picker.test.ts | 115 --- e2e/scenarios/custom-tools.test.ts | 534 ------------ package.json | 2 +- packages/plugins/apps/CHANGELOG.md | 61 -- packages/plugins/apps/package.json | 84 -- packages/plugins/apps/src/api.ts | 18 - packages/plugins/apps/src/authoring.test.ts | 25 - packages/plugins/apps/src/authoring.ts | 126 --- packages/plugins/apps/src/cloud.ts | 3 - .../apps/src/cloudflare-workers-test.d.ts | 3 - .../app-tool-executor.conformance.test.ts | 15 - .../src/executor/app-tool-executor.test.ts | 251 ------ .../apps/src/executor/app-tool-executor.ts | 503 ----------- .../dynamic-worker-app-tool-executor.ts | 345 -------- .../src/executor/workerd-app-tool-executor.ts | 326 ------- packages/plugins/apps/src/git-client/hand.ts | 135 --- .../plugins/apps/src/git-client/packfile.ts | 609 ------------- .../plugins/apps/src/git-client/pktline.ts | 140 --- .../plugins/apps/src/git-client/transport.ts | 300 ------- .../apps/src/git-client/url-security.ts | 154 ---- packages/plugins/apps/src/index.ts | 7 - packages/plugins/apps/src/pipeline/bundle.ts | 43 - .../apps/src/pipeline/bundler-driver.ts | 194 ----- .../plugins/apps/src/pipeline/descriptor.ts | 75 -- .../plugins/apps/src/pipeline/discover.ts | 77 -- .../src/pipeline/dynamic-worker-bundler.ts | 138 --- .../dynamic-worker-bundler.worker.test.ts | 177 ---- .../apps/src/pipeline/esbuild-bundler.ts | 172 ---- .../plugins/apps/src/pipeline/publish.test.ts | 270 ------ packages/plugins/apps/src/pipeline/publish.ts | 282 ------ .../src/pipeline/worker-bundler-artifact.ts | 43 - .../src/pipeline/worker-bundler-version.ts | 2 - .../apps/src/pipeline/worker-bundler.test.ts | 178 ---- .../apps/src/pipeline/worker-bundler.ts | 85 -- .../apps/src/plugin/apps-plugin.test.ts | 813 ------------------ .../plugins/apps/src/plugin/apps-plugin.ts | 709 --------------- .../plugins/apps/src/plugin/bindings.test.ts | 131 --- packages/plugins/apps/src/plugin/bindings.ts | 176 ---- packages/plugins/apps/src/plugin/handlers.ts | 129 --- packages/plugins/apps/src/plugin/resolver.ts | 152 ---- packages/plugins/apps/src/plugin/routes.ts | 143 --- .../plugins/apps/src/plugin/store.test.ts | 225 ----- packages/plugins/apps/src/plugin/store.ts | 312 ------- .../apps/src/react/AddCustomToolsSource.tsx | 459 ---------- .../src/react/CustomToolsAccountsPanel.tsx | 292 ------- .../apps/src/react/custom-tools-client.ts | 375 -------- .../src/react/custom-tools-client.web.test.ts | 267 ------ packages/plugins/apps/src/react/index.ts | 2 - .../plugins/apps/src/react/plugin-client.tsx | 16 - .../apps/src/react/source-panel-model.ts | 156 ---- .../plugins/apps/src/react/source-plugin.tsx | 36 - packages/plugins/apps/src/selfhost.ts | 3 - .../apps/src/source/app-source.test.ts | 579 ------------- .../plugins/apps/src/source/app-source.ts | 40 - .../src/source/fixtures/git-fixture-shas.txt | 2 - .../src/source/fixtures/git-fixture-v1.pack | Bin 507 -> 0 bytes .../src/source/fixtures/git-fixture-v2.pack | Bin 850 -> 0 bytes .../plugins/apps/src/source/git-source.ts | 178 ---- .../apps/src/source/local-directory-source.ts | 362 -------- .../plugins/apps/src/source/relevant-files.ts | 24 - packages/plugins/apps/src/standard-schema.ts | 21 - .../plugins/apps/src/testing/conformance.ts | 168 ---- packages/plugins/apps/src/testing/index.ts | 3 - packages/plugins/apps/src/vite.ts | 74 -- packages/plugins/apps/tsconfig.json | 24 - packages/plugins/apps/tsup.config.ts | 16 - packages/plugins/apps/vitest.config.ts | 10 - packages/plugins/apps/vitest.worker.config.ts | 58 -- packages/plugins/apps/wrangler.worker.jsonc | 11 - .../react/src/lib/integration-detail-tabs.ts | 12 +- packages/react/src/pages/integration-add.tsx | 4 +- .../react/src/pages/integration-detail.tsx | 16 +- packages/react/src/pages/integrations.tsx | 52 -- 99 files changed, 33 insertions(+), 12738 deletions(-) create mode 100644 .changeset/remove-apps-plugin.md delete mode 100644 apps/cli/scripts/smoke-packed-apps.ts delete mode 100644 apps/cloud/scripts/assert-worker-bundler-assets.mjs delete mode 100644 apps/cloud/src/worker-bundler-artifact.d.ts delete mode 100644 e2e/cli/custom-tools-packed.test.ts delete mode 100644 e2e/desktop-vm/custom-tools-sidecar.test.ts delete mode 100644 e2e/fixtures/custom-tools-git/custom-tools-shas.txt delete mode 100644 e2e/fixtures/custom-tools-git/custom-tools-v1.pack delete mode 100644 e2e/fixtures/custom-tools-git/custom-tools-v2.pack delete mode 100644 e2e/fixtures/custom-tools-git/custom-tools-v3.pack delete mode 100644 e2e/fixtures/custom-tools-git/generate.ts delete mode 100644 e2e/local/custom-tools-directory-picker.test.ts delete mode 100644 e2e/scenarios/custom-tools.test.ts delete mode 100644 packages/plugins/apps/CHANGELOG.md delete mode 100644 packages/plugins/apps/package.json delete mode 100644 packages/plugins/apps/src/api.ts delete mode 100644 packages/plugins/apps/src/authoring.test.ts delete mode 100644 packages/plugins/apps/src/authoring.ts delete mode 100644 packages/plugins/apps/src/cloud.ts delete mode 100644 packages/plugins/apps/src/cloudflare-workers-test.d.ts delete mode 100644 packages/plugins/apps/src/executor/app-tool-executor.conformance.test.ts delete mode 100644 packages/plugins/apps/src/executor/app-tool-executor.test.ts delete mode 100644 packages/plugins/apps/src/executor/app-tool-executor.ts delete mode 100644 packages/plugins/apps/src/executor/dynamic-worker-app-tool-executor.ts delete mode 100644 packages/plugins/apps/src/executor/workerd-app-tool-executor.ts delete mode 100644 packages/plugins/apps/src/git-client/hand.ts delete mode 100644 packages/plugins/apps/src/git-client/packfile.ts delete mode 100644 packages/plugins/apps/src/git-client/pktline.ts delete mode 100644 packages/plugins/apps/src/git-client/transport.ts delete mode 100644 packages/plugins/apps/src/git-client/url-security.ts delete mode 100644 packages/plugins/apps/src/index.ts delete mode 100644 packages/plugins/apps/src/pipeline/bundle.ts delete mode 100644 packages/plugins/apps/src/pipeline/bundler-driver.ts delete mode 100644 packages/plugins/apps/src/pipeline/descriptor.ts delete mode 100644 packages/plugins/apps/src/pipeline/discover.ts delete mode 100644 packages/plugins/apps/src/pipeline/dynamic-worker-bundler.ts delete mode 100644 packages/plugins/apps/src/pipeline/dynamic-worker-bundler.worker.test.ts delete mode 100644 packages/plugins/apps/src/pipeline/esbuild-bundler.ts delete mode 100644 packages/plugins/apps/src/pipeline/publish.test.ts delete mode 100644 packages/plugins/apps/src/pipeline/publish.ts delete mode 100644 packages/plugins/apps/src/pipeline/worker-bundler-artifact.ts delete mode 100644 packages/plugins/apps/src/pipeline/worker-bundler-version.ts delete mode 100644 packages/plugins/apps/src/pipeline/worker-bundler.test.ts delete mode 100644 packages/plugins/apps/src/pipeline/worker-bundler.ts delete mode 100644 packages/plugins/apps/src/plugin/apps-plugin.test.ts delete mode 100644 packages/plugins/apps/src/plugin/apps-plugin.ts delete mode 100644 packages/plugins/apps/src/plugin/bindings.test.ts delete mode 100644 packages/plugins/apps/src/plugin/bindings.ts delete mode 100644 packages/plugins/apps/src/plugin/handlers.ts delete mode 100644 packages/plugins/apps/src/plugin/resolver.ts delete mode 100644 packages/plugins/apps/src/plugin/routes.ts delete mode 100644 packages/plugins/apps/src/plugin/store.test.ts delete mode 100644 packages/plugins/apps/src/plugin/store.ts delete mode 100644 packages/plugins/apps/src/react/AddCustomToolsSource.tsx delete mode 100644 packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx delete mode 100644 packages/plugins/apps/src/react/custom-tools-client.ts delete mode 100644 packages/plugins/apps/src/react/custom-tools-client.web.test.ts delete mode 100644 packages/plugins/apps/src/react/index.ts delete mode 100644 packages/plugins/apps/src/react/plugin-client.tsx delete mode 100644 packages/plugins/apps/src/react/source-panel-model.ts delete mode 100644 packages/plugins/apps/src/react/source-plugin.tsx delete mode 100644 packages/plugins/apps/src/selfhost.ts delete mode 100644 packages/plugins/apps/src/source/app-source.test.ts delete mode 100644 packages/plugins/apps/src/source/app-source.ts delete mode 100644 packages/plugins/apps/src/source/fixtures/git-fixture-shas.txt delete mode 100644 packages/plugins/apps/src/source/fixtures/git-fixture-v1.pack delete mode 100644 packages/plugins/apps/src/source/fixtures/git-fixture-v2.pack delete mode 100644 packages/plugins/apps/src/source/git-source.ts delete mode 100644 packages/plugins/apps/src/source/local-directory-source.ts delete mode 100644 packages/plugins/apps/src/source/relevant-files.ts delete mode 100644 packages/plugins/apps/src/standard-schema.ts delete mode 100644 packages/plugins/apps/src/testing/conformance.ts delete mode 100644 packages/plugins/apps/src/testing/index.ts delete mode 100644 packages/plugins/apps/src/vite.ts delete mode 100644 packages/plugins/apps/tsconfig.json delete mode 100644 packages/plugins/apps/tsup.config.ts delete mode 100644 packages/plugins/apps/vitest.config.ts delete mode 100644 packages/plugins/apps/vitest.worker.config.ts delete mode 100644 packages/plugins/apps/wrangler.worker.jsonc diff --git a/.changeset/remove-apps-plugin.md b/.changeset/remove-apps-plugin.md new file mode 100644 index 0000000000..c5e0246bbc --- /dev/null +++ b/.changeset/remove-apps-plugin.md @@ -0,0 +1,7 @@ +--- +"executor": minor +--- + +Remove the custom apps plugin. Git and local-directory app sources are no +longer supported. The packed binary still ships the workerd and worker-bundler +sidecars. diff --git a/apps/cli/package.json b/apps/cli/package.json index 3953a85f96..19f48141e4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -36,9 +36,11 @@ "quickjs-emscripten": "catalog:" }, "devDependencies": { + "@cloudflare/worker-bundler": "0.2.1", "@effect/vitest": "catalog:", "@types/node": "catalog:", "bun-types": "catalog:", + "esbuild": "^0.27.3", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/apps/cli/scripts/smoke-packed-apps.ts b/apps/cli/scripts/smoke-packed-apps.ts deleted file mode 100644 index 633cf64873..0000000000 --- a/apps/cli/scripts/smoke-packed-apps.ts +++ /dev/null @@ -1,192 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-promise-reject, executor/no-promise-catch -- boundary: standalone build smoke harness tears down a spawned packed binary */ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { spawn, type Subprocess } from "bun"; - -const binary = process.argv[2]; -if (!binary) throw new Error("usage: bun apps/cli/scripts/smoke-packed-apps.ts "); - -const AUTH_TOKEN = "packed-apps-smoke-token"; -const READY_TIMEOUT_MS = 45_000; - -const fail = (message: string): never => { - throw new Error(`[smoke-packed-apps] FAIL: ${message}`); -}; - -const requestJson = async (origin: string, path: string, init: RequestInit = {}): Promise => { - const response = await fetch(new URL(path, origin), { - ...init, - headers: { - authorization: `Bearer ${AUTH_TOKEN}`, - ...(init.body === undefined ? {} : { "content-type": "application/json" }), - ...init.headers, - }, - }); - const text = await response.text(); - if (!response.ok) fail(`${init.method ?? "GET"} ${path} returned ${response.status}: ${text}`); - return (text.length > 0 ? JSON.parse(text) : null) as T; -}; - -const waitForReadyPort = async (proc: Subprocess<"ignore", "pipe", "pipe">): Promise => - new Promise((resolveReady, rejectReady) => { - const timer = setTimeout( - () => rejectReady(new Error("packed binary did not report ready in time")), - READY_TIMEOUT_MS, - ); - let stdout = ""; - let stderr = ""; - const finish = (result: number | Error): void => { - clearTimeout(timer); - if (result instanceof Error) rejectReady(result); - else resolveReady(result); - }; - void proc.exited.then((code) => { - finish(new Error(`packed binary exited before ready with code ${code}: ${stderr}`)); - }); - void (async () => { - const reader = proc.stdout.getReader(); - const decoder = new TextDecoder(); - try { - while (true) { - const chunk = await reader.read(); - if (chunk.done) return; - stdout += decoder.decode(chunk.value); - const match = stdout.match(/EXECUTOR_READY:(\d+)/); - if (match) { - finish(Number(match[1])); - return; - } - } - } catch (cause) { - finish(cause instanceof Error ? cause : new Error(String(cause))); - } - })(); - void (async () => { - const reader = proc.stderr.getReader(); - const decoder = new TextDecoder(); - while (true) { - const chunk = await reader.read(); - if (chunk.done) return; - stderr += decoder.decode(chunk.value); - } - })().catch(() => undefined); - }); - -const main = async (): Promise => { - const fixture = await mkdtemp(join(tmpdir(), "executor-packed-apps-fixture-")); - const dataDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-data-")); - const scopeDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-scope-")); - let proc: Subprocess<"ignore", "pipe", "pipe"> | null = null; - try { - await mkdir(join(fixture, "tools"), { recursive: true }); - await writeFile( - join(fixture, "tools", "greet.ts"), - ` - import { z } from "zod"; - import { defineTool } from "executor:app"; - - const Input = z.object({ name: z.string() }); - - export default defineTool({ - description: "Greet", - input: Input, - async handler(input) { - return { greeting: "hello " + input.name }; - }, - }); - `, - ); - await writeFile( - join(fixture, "package.json"), - `${JSON.stringify({ name: "packed-apps-smoke", dependencies: { zod: "4.3.6" } })}\n`, - ); - await writeFile(join(fixture, "bun.lock"), ""); - - proc = spawn({ - cmd: [ - resolve(binary), - "daemon", - "run", - "--foreground", - "--port", - "0", - "--hostname", - "127.0.0.1", - "--auth-token", - AUTH_TOKEN, - ], - env: { - ...process.env, - EXECUTOR_CLIENT: "desktop", - EXECUTOR_DATA_DIR: dataDir, - EXECUTOR_SCOPE_DIR: scopeDir, - }, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - const port = await waitForReadyPort(proc); - const origin = `http://127.0.0.1:${port}`; - console.log(`[smoke-packed-apps] ready on ${origin}`); - - await requestJson(origin, "/api/apps/sources", { - method: "POST", - body: JSON.stringify({ - kind: "local-directory", - slug: "packed-apps-smoke", - app: "packed-apps-smoke", - path: fixture, - }), - }); - - const sync = await requestJson<{ - readonly status: string; - readonly tools: readonly string[]; - readonly errors?: readonly unknown[]; - }>(origin, "/api/apps/sources/packed-apps-smoke/sync", { method: "POST" }); - if (sync.status !== "published") fail(`sync failed: ${JSON.stringify(sync.errors)}`); - if (JSON.stringify(sync.tools) !== JSON.stringify(["greet"])) { - fail(`expected greet tool, got ${JSON.stringify(sync.tools)}`); - } - - const invoked = await requestJson<{ - readonly status: string; - readonly structured: unknown; - readonly isError: boolean; - readonly text: string; - }>(origin, "/api/executions", { - method: "POST", - body: JSON.stringify({ - code: `export default await tools["packed-apps-smoke.org.published.greet"]({ name: "Ada" })`, - autoApprove: true, - }), - }); - if (invoked.status !== "completed" || invoked.isError) { - fail(`execution failed: ${JSON.stringify(invoked)}`); - } - if ( - JSON.stringify(invoked.structured) !== - JSON.stringify({ - status: "completed", - result: { ok: true, data: { greeting: "hello Ada" } }, - logs: [], - }) - ) { - fail(`unexpected execution result: ${JSON.stringify(invoked)}`); - } - console.log("[smoke-packed-apps] OK - local-directory app source bundled and invoked"); - } finally { - if (proc) { - proc.kill("SIGTERM"); - await Promise.race([proc.exited, new Promise((resolve) => setTimeout(resolve, 3000))]); - proc.kill("SIGKILL"); - } - await rm(fixture, { recursive: true, force: true }).catch(() => undefined); - await rm(dataDir, { recursive: true, force: true }).catch(() => undefined); - await rm(scopeDir, { recursive: true, force: true }).catch(() => undefined); - } -}; - -await main(); diff --git a/apps/cli/src/build.ts b/apps/cli/src/build.ts index 0f61ec225e..64117dc861 100644 --- a/apps/cli/src/build.ts +++ b/apps/cli/src/build.ts @@ -36,7 +36,7 @@ const resolveOnePasswordCoreWasmPath = (): string => { }; const resolveWorkerBundlerDistPath = (): string => { - const req = createRequire(join(repoRoot, "packages/plugins/apps/package.json")); + const req = createRequire(join(repoRoot, "apps/cli/package.json")); const pkgJson = req.resolve("@cloudflare/worker-bundler/package.json"); const distPath = join(dirname(pkgJson), "dist"); if (!existsSync(join(distPath, "index.js")) || !existsSync(join(distPath, "esbuild.wasm"))) { @@ -463,15 +463,6 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { console.log(` Smoke test: ${bin} --version`); const version = await $`${bin} --version`.text(); console.log(` OK: ${version.trim()}`); - console.log(` Smoke test: packed apps source sync and invoke`); - const smoke = Bun.spawn( - ["bun", "run", join(cliRoot, "scripts/smoke-packed-apps.ts"), bin], - { - cwd: repoRoot, - stdio: ["ignore", "inherit", "inherit"], - }, - ); - if ((await smoke.exited) !== 0) throw new Error("packed apps smoke failed"); } // Variant package.json. All variants publish to the SAME npm package diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 5f2759f899..71cd359999 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -10,11 +10,6 @@ import { } from "@executor-js/plugin-openapi/providers/microsoft"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; -import { - makeDynamicWorkerAppToolExecutor, - makeDynamicWorkerBundlerBackend, -} from "@executor-js/plugin-apps/cloud"; -import { appsHttpPlugin } from "@executor-js/plugin-apps/api"; import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; @@ -53,61 +48,10 @@ interface CloudPluginDeps { * falls back to the credential-driven default. */ readonly workosVaultClient?: WorkOSVaultClient; readonly activeToolkitSlug?: string; - /** Mirrors `HostConfig.allowLocalNetwork` (`ALLOW_LOCAL_NETWORK`). Off by - * default; production leaves it unset. */ - readonly allowLocalNetwork?: boolean; - readonly workerLoader?: { - readonly get: ( - name: string | null, - factory: () => { - readonly compatibilityDate: string; - readonly compatibilityFlags?: readonly string[]; - readonly mainModule: string; - readonly modules: Readonly>; - readonly globalOutbound?: null; - }, - ) => { readonly getEntrypoint: () => unknown }; - readonly load?: (code: { - readonly compatibilityDate: string; - readonly compatibilityFlags?: readonly string[]; - readonly mainModule: string; - readonly modules: Readonly>; - readonly globalOutbound?: null; - }) => { readonly getEntrypoint: () => unknown }; - }; - readonly workerAssets?: { - readonly fetch: (request: Request) => Promise; - }; } -const base64ToArrayBuffer = (value: string): ArrayBuffer => { - const bytes = Uint8Array.from(atob(value), (char) => char.charCodeAt(0)); - return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); -}; - -const assetRequest = (path: string): Request => new Request(new URL(path, "https://assets.local")); - -const fetchAsset = async ( - assets: { readonly fetch: (request: Request) => Promise }, - path: string, -): Promise => { - const response = await assets.fetch(assetRequest(path)); - if (!response.ok) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: worker artifact asset fetch must reject the dynamic bundler setup - throw new Error(`failed to fetch worker-bundler asset ${path}: ${response.status}`); - } - return response; -}; - export default defineExecutorConfig({ - plugins: ({ - workosCredentials, - workosVaultClient, - activeToolkitSlug, - allowLocalNetwork, - workerLoader, - workerAssets, - }: CloudPluginDeps = {}) => + plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], @@ -117,43 +61,6 @@ export default defineExecutorConfig({ dangerouslyAllowStdioMCP: false, }), graphqlHttpPlugin(), - appsHttpPlugin({ - ...(workerLoader - ? { - executor: makeDynamicWorkerAppToolExecutor({ loader: workerLoader }), - bundler: makeDynamicWorkerBundlerBackend({ - loader: workerLoader, - artifact: async () => { - const artifact = await import("virtual:executor/worker-bundler-artifact"); - if (artifact.source !== undefined && artifact.wasmBase64 !== undefined) { - return { - source: artifact.source, - wasm: base64ToArrayBuffer(artifact.wasmBase64), - }; - } - if (workerAssets === undefined) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: worker artifact asset binding is required for production bundler setup - throw new Error("worker-bundler artifact assets binding is unavailable"); - } - const [source, wasm] = await Promise.all([ - fetchAsset(workerAssets, artifact.sourcePath).then((response) => - response.text(), - ), - fetchAsset(workerAssets, artifact.wasmPath).then((response) => - response.arrayBuffer(), - ), - ]); - return { - source, - wasm, - }; - }, - }), - } - : {}), - sourceKinds: ["git"], - allowPrivateGitHosts: allowLocalNetwork === true, - }), toolkitsPlugin({ activeToolkitSlug }), workosVaultPlugin({ credentials: workosCredentials ?? { apiKey: "", clientId: "" }, diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 74276f6a6d..990b428947 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -41,7 +41,6 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", diff --git a/apps/cloud/scripts/assert-worker-bundler-assets.mjs b/apps/cloud/scripts/assert-worker-bundler-assets.mjs deleted file mode 100644 index 71851487f6..0000000000 --- a/apps/cloud/scripts/assert-worker-bundler-assets.mjs +++ /dev/null @@ -1,54 +0,0 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; - -const appRoot = new URL("..", import.meta.url); -const serverAssetsDir = new URL("dist/server/assets/", appRoot); -const clientAssetsDir = new URL("dist/client/assets/", appRoot); -const largeFileLimit = 3 * 1024 * 1024; -const sourceAssetMinimum = 512 * 1024; - -const fail = (message) => { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build assertion script reports a failed artifact contract - throw new Error(message); -}; - -const fileSize = (dir, name) => statSync(join(dir.pathname, name)).size; - -const serverAssets = readdirSync(serverAssetsDir); -for (const name of serverAssets) { - const path = join(serverAssetsDir.pathname, name); - const size = statSync(path).size; - if (size <= largeFileLimit) continue; - if (name.includes("worker-bundler")) { - fail(`server assets include oversized worker-bundler chunk ${name}`); - } - const sample = readFileSync(path, "utf8"); - if (sample.includes("worker-bundler")) { - fail(`server assets include worker-bundler content in oversized chunk ${name}`); - } -} - -const clientAssets = readdirSync(clientAssetsDir); -const source = clientAssets.find((name) => /^worker-bundler-source-[a-f0-9]{12}\.js$/.test(name)); -const wasm = clientAssets.find((name) => - /^worker-bundler-esbuild-wasm-[a-f0-9]{12}\.wasm$/.test(name), -); - -if (source === undefined) fail("missing worker-bundler source asset"); -if (wasm === undefined) fail("missing worker-bundler esbuild wasm asset"); - -const sourceSize = fileSize(clientAssetsDir, source); -const wasmSize = fileSize(clientAssetsDir, wasm); -if (sourceSize <= sourceAssetMinimum) { - fail(`worker-bundler source asset is unexpectedly small: ${source}`); -} -if (wasmSize <= largeFileLimit) fail(`worker-bundler wasm asset is unexpectedly small: ${wasm}`); - -const wasmMagic = readFileSync(join(clientAssetsDir.pathname, wasm)).subarray(0, 4); -if (!wasmMagic.equals(Buffer.from([0x00, 0x61, 0x73, 0x6d]))) { - fail(`worker-bundler wasm asset does not start with wasm magic bytes: ${wasm}`); -} - -console.log( - `[assert-worker-bundler-assets] ${source} (${sourceSize} bytes), ${wasm} (${wasmSize} bytes)`, -); diff --git a/apps/cloud/scripts/build.mjs b/apps/cloud/scripts/build.mjs index 707bfc60a4..aeae418e39 100644 --- a/apps/cloud/scripts/build.mjs +++ b/apps/cloud/scripts/build.mjs @@ -22,9 +22,8 @@ const steps = [ // Workspace packages whose exports app code (or this very vite config) // resolves from dist under Node — vite's config loader externalizes bare // imports, so they must be built before vite starts. - "turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react --filter @executor-js/plugin-apps", + "turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react", "vite build", - "node scripts/assert-worker-bundler-assets.mjs", ]; for (const step of steps) { diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index deba6b7e4d..869bf58167 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -62,9 +62,6 @@ const cloudPluginFactory = executorConfig.plugins as (deps: { readonly apiUrl?: string; }; readonly activeToolkitSlug?: string; - readonly allowLocalNetwork?: boolean; - readonly workerLoader?: WorkerLoader; - readonly workerAssets?: { readonly fetch: (request: Request) => Promise }; }) => readonly AnyPlugin[]; // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault @@ -79,9 +76,6 @@ export const CloudPluginsProvider: Layer.Layer = Layer.succeed( }, activeToolkitSlug: context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, - allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", - workerLoader: env.LOADER, - workerAssets: env.ASSETS, }), }); diff --git a/apps/cloud/src/worker-bundler-artifact.d.ts b/apps/cloud/src/worker-bundler-artifact.d.ts deleted file mode 100644 index ad70339ab6..0000000000 --- a/apps/cloud/src/worker-bundler-artifact.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module "virtual:executor/worker-bundler-artifact" { - export const sourcePath: string; - export const wasmPath: string; - export const source: string | undefined; - export const wasmBase64: string | undefined; -} diff --git a/apps/cloud/vite.config.ts b/apps/cloud/vite.config.ts index 83be48137c..4cdbd7cf43 100644 --- a/apps/cloud/vite.config.ts +++ b/apps/cloud/vite.config.ts @@ -5,7 +5,6 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; -import { workerBundlerArtifact } from "@executor-js/plugin-apps/vite"; import { unstable_readConfig } from "wrangler"; import { routes } from "./tsr.routes"; @@ -126,7 +125,6 @@ export default defineConfig(({ command, mode }) => { }, plugins: [ devCrashGuard(), - workerBundlerArtifact(), tailwindcss(), executorVitePlugin(), cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }), diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 267f428ef1..dfd9bbd800 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -5,8 +5,7 @@ "compatibility_flags": ["nodejs_compat"], "main": "src/server.ts", // The vite plugin fills in assets.directory (dist/client); the binding must - // be declared here or env.ASSETS does not exist at runtime. The apps plugin - // fetches the worker-bundler artifact through it on first sync. + // be declared here or env.ASSETS does not exist at runtime. "assets": { "binding": "ASSETS", }, diff --git a/apps/host-selfhost/executor.config.ts b/apps/host-selfhost/executor.config.ts index fbd6884398..d09d5c1b65 100644 --- a/apps/host-selfhost/executor.config.ts +++ b/apps/host-selfhost/executor.config.ts @@ -10,11 +10,6 @@ import { } from "@executor-js/plugin-openapi/providers/microsoft"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; -import { - makeWorkerdAppToolExecutor, - makeWorkerBundlerBackend, -} from "@executor-js/plugin-apps/selfhost"; -import { appsHttpPlugin } from "@executor-js/plugin-apps/api"; import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; @@ -32,14 +27,13 @@ import { resolveSecretKey } from "./src/config"; interface SelfHostPluginDeps { readonly activeToolkitSlug?: string; - readonly sourceKinds?: readonly ("git" | "local-directory")[]; /** Accepted for test-harness parity; the Microsoft Graph URL override moved * into the OpenAPI provider presets, so the factory no longer reads it. */ readonly allowLocalNetwork?: boolean; } export default defineExecutorConfig({ - plugins: ({ activeToolkitSlug, sourceKinds }: SelfHostPluginDeps = {}) => + plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) => [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], @@ -47,12 +41,6 @@ export default defineExecutorConfig({ }), mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), graphqlHttpPlugin(), - appsHttpPlugin({ - executor: makeWorkerdAppToolExecutor(), - bundler: makeWorkerBundlerBackend(), - sourceKinds: sourceKinds ?? ["git"], - allowPrivateGitHosts: true, - }), toolkitsPlugin({ activeToolkitSlug }), // First writable secret provider -> the default for `secrets.set`. encryptedSecretsPlugin({ key: resolveSecretKey() }), diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index 34fa9ea8f5..c40c7e95bf 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@better-auth/api-key": "^1.6.11", + "@cloudflare/worker-bundler": "0.2.1", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", "@executor-js/api": "workspace:*", @@ -26,7 +27,6 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -43,8 +43,10 @@ "better-auth": "^1.6.11", "drizzle-orm": "catalog:", "effect": "catalog:", + "esbuild": "^0.27.3", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "workerd": "1.20260708.1" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/apps/host-selfhost/scripts/package-runtime.ts b/apps/host-selfhost/scripts/package-runtime.ts index 9c76b14249..5329aad607 100644 --- a/apps/host-selfhost/scripts/package-runtime.ts +++ b/apps/host-selfhost/scripts/package-runtime.ts @@ -91,8 +91,7 @@ const copyPackage = (name: string): void => { cpSync(packageDir(name), destination, { recursive: true, dereference: true }); }; -// The worker-bundler backend (packages/plugins/apps/src/pipeline/ -// worker-bundler-artifact.ts) loads `dist/index.bundled.js` from the resolved +// Worker-bundler consumers load `dist/index.bundled.js` from the resolved // package, falling back to bundling `dist/index.js` with esbuild at runtime. // The npm package doesn't ship the bundled entry and the runtime image has no // esbuild, so produce it here, the same way apps/cli/src/build.ts does for the diff --git a/apps/host-selfhost/src/boot.test.ts b/apps/host-selfhost/src/boot.test.ts index 369308f166..c14707a043 100644 --- a/apps/host-selfhost/src/boot.test.ts +++ b/apps/host-selfhost/src/boot.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -97,109 +97,3 @@ test("POST /executions runs code in the QuickJS sandbox", async () => { expect(body.text).toBe("42"); expect(body.isError).toBe(false); }); - -test("selfhost rejects local-directory app sources over HTTP", async () => { - const created = await handler( - new Request("http://localhost/api/apps/sources", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - kind: "local-directory", - slug: "local-greeter-disabled", - app: "local-greeter-disabled", - path: "/tmp/local-greeter-disabled", - }), - }), - ); - expect(created.status).toBe(500); - - const dirs = await handler(new Request("http://localhost/api/apps/fs/dirs")); - expect(dirs.status).toBe(404); -}); - -test("apps source sync publishes and invokes a local-directory tool over HTTP", async () => { - const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app"); - const localDirectoryApp = await makeSelfHostTestApp({ - identity: singleAdminIdentityLayer({ - userId: "admin-local-directory", - organizationId: "default-org-local-directory", - organizationName: "Default Local Directory", - }), - // This test exercises the plugin loop, not the selfhost product posture. - pluginDeps: { sourceKinds: ["git", "local-directory"] }, - }); - const root = mkdtempSync(join(tmpdir(), "eh-app-src-")); - mkdirSync(join(root, "tools"), { recursive: true }); - writeFileSync( - join(root, "tools", "greeter.ts"), - ` - import { defineTool } from "executor:app"; - - export default defineTool({ - description: "Greets through the app source path", - input: { - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - }, - async handler(input) { - return { greeting: "hello " + input.name }; - }, - }); - `, - ); - writeFileSync(join(root, "package.json"), JSON.stringify({ name: "local-greeter" })); - - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test fixture must dispose the booted app after assertions - try { - const created = await localDirectoryApp.handler( - new Request("http://localhost/api/apps/sources", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - kind: "local-directory", - slug: "local-greeter", - app: "local-greeter", - path: root, - }), - }), - ); - expect(created.status).toBe(200); - - const synced = await localDirectoryApp.handler( - new Request("http://localhost/api/apps/sources/local-greeter/sync", { method: "POST" }), - ); - expect(synced.status).toBe(200); - const syncBody = (await synced.json()) as { - readonly status: string; - readonly tools: readonly string[]; - readonly errors?: readonly unknown[]; - }; - expect(syncBody.status, JSON.stringify(syncBody.errors)).toBe("published"); - expect(syncBody.tools).toEqual(["greeter"]); - - const listed = await localDirectoryApp.handler(new Request("http://localhost/api/tools")); - expect(listed.status).toBe(200); - const tools = (await listed.json()) as ReadonlyArray<{ readonly address: string }>; - expect( - tools.some((tool) => tool.address === "tools.local-greeter.org.published.greeter"), - JSON.stringify(tools.map((tool) => tool.address)), - ).toBe(true); - - const invoked = await localDirectoryApp.handler( - new Request("http://localhost/api/executions", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - code: 'export default await tools["local-greeter.org.published.greeter"]({ name: "Ada" })', - }), - }), - ); - expect(invoked.status).toBe(200); - const output = (await invoked.json()) as { readonly text: string; readonly isError: boolean }; - expect(output.isError).toBe(false); - expect(output.text).toContain("hello Ada"); - } finally { - await localDirectoryApp.dispose(); - } -}); diff --git a/apps/local/executor.config.ts b/apps/local/executor.config.ts index ad14b85247..afb00f7c88 100644 --- a/apps/local/executor.config.ts +++ b/apps/local/executor.config.ts @@ -1,9 +1,4 @@ import { defineExecutorConfig } from "@executor-js/sdk"; -import { - makeWorkerBundlerBackend, - makeWorkerdAppToolExecutor, -} from "@executor-js/plugin-apps/selfhost"; -import { appsHttpPlugin } from "@executor-js/plugin-apps/api"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { googleCatalog, @@ -43,12 +38,6 @@ export default defineExecutorConfig({ }), mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), graphqlHttpPlugin(), - appsHttpPlugin({ - executor: makeWorkerdAppToolExecutor(), - bundler: makeWorkerBundlerBackend(), - sourceKinds: ["git", "local-directory"], - allowPrivateGitHosts: true, - }), toolkitsPlugin({ activeToolkitSlug }), keychainPlugin(), fileSecretsPlugin(), diff --git a/apps/local/package.json b/apps/local/package.json index 5f8edaefcc..a6b2ade66b 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -29,7 +29,6 @@ "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/integrations-registry": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", "@executor-js/plugin-desktop-settings": "workspace:*", "@executor-js/plugin-example": "workspace:*", "@executor-js/plugin-file-secrets": "workspace:*", diff --git a/bun.lock b/bun.lock index bc5ca40ea5..f64b03c51d 100644 --- a/bun.lock +++ b/bun.lock @@ -49,9 +49,11 @@ "quickjs-emscripten": "catalog:", }, "devDependencies": { + "@cloudflare/worker-bundler": "0.2.1", "@effect/vitest": "catalog:", "@types/node": "catalog:", "bun-types": "catalog:", + "esbuild": "^0.27.3", "typescript": "catalog:", "vitest": "catalog:", }, @@ -68,7 +70,6 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", @@ -216,6 +217,7 @@ "version": "0.0.34", "dependencies": { "@better-auth/api-key": "^1.6.11", + "@cloudflare/worker-bundler": "0.2.1", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", "@executor-js/api": "workspace:*", @@ -223,7 +225,6 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -240,8 +241,10 @@ "better-auth": "^1.6.11", "drizzle-orm": "catalog:", "effect": "catalog:", + "esbuild": "^0.27.3", "react": "catalog:", "react-dom": "catalog:", + "workerd": "1.20260708.1", }, "devDependencies": { "@effect/vitest": "catalog:", @@ -272,7 +275,6 @@ "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/integrations-registry": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", "@executor-js/plugin-desktop-settings": "workspace:*", "@executor-js/plugin-example": "workspace:*", "@executor-js/plugin-file-secrets": "workspace:*", @@ -792,45 +794,6 @@ "vitest": "catalog:", }, }, - "packages/plugins/apps": { - "name": "@executor-js/plugin-apps", - "version": "0.1.6", - "dependencies": { - "@cloudflare/worker-bundler": "0.2.1", - "@executor-js/api": "workspace:*", - "@executor-js/runtime-workerd-subprocess": "workspace:*", - "@executor-js/sdk": "workspace:*", - "esbuild": "^0.27.3", - "zod": "4.3.6", - }, - "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.15.0", - "@effect/atom-react": "catalog:", - "@effect/vitest": "catalog:", - "@executor-js/react": "workspace:*", - "@types/node": "catalog:", - "@types/react": "catalog:", - "bun-types": "catalog:", - "effect": "catalog:", - "react": "catalog:", - "tsup": "catalog:", - "vite": "catalog:", - "vitest": "catalog:", - }, - "peerDependencies": { - "@effect/atom-react": "catalog:", - "@executor-js/react": "workspace:*", - "@tanstack/react-router": "catalog:", - "effect": "catalog:", - "react": "catalog:", - }, - "optionalPeers": [ - "@effect/atom-react", - "@executor-js/react", - "@tanstack/react-router", - "react", - ], - }, "packages/plugins/desktop-settings": { "name": "@executor-js/plugin-desktop-settings", "version": "1.5.35", @@ -1771,8 +1734,6 @@ "@executor-js/motel": ["@executor-js/motel@0.2.5-executor.1", "", { "dependencies": { "@effect/atom-react": "^4.0.0-beta.49", "@effect/opentelemetry": "^4.0.0-beta.49", "@effect/platform-bun": "^4.0.0-beta.50", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/sdk-logs": "^0.214.0", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentui/core": "^0.1.99", "@opentui/react": "^0.1.99", "effect": "^4.0.0-beta.49", "react": "^19.2.5", "scheduler": "^0.27.0" }, "bin": { "motel": "src/motel.ts", "motel-mcp": "src/mcp.ts" } }, "sha512-fLGJ5FIIBYd+4L2OurpYFjWzvcCbACvmdNofh6THXQJE1Gku93EKURtgn27/XddyM9SKGST6/znTRcmwKiYdXw=="], - "@executor-js/plugin-apps": ["@executor-js/plugin-apps@workspace:packages/plugins/apps"], - "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"], "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"], @@ -5915,7 +5876,7 @@ "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@cloudflare/worker-bundler/es-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], + "@cloudflare/worker-bundler/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], "@cloudflare/worker-bundler/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], diff --git a/e2e/cli/custom-tools-packed.test.ts b/e2e/cli/custom-tools-packed.test.ts deleted file mode 100644 index 604e45b6a7..0000000000 --- a/e2e/cli/custom-tools-packed.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e SSH and raw HTTP assertions */ -// Proves the compiled executor binary's bundled workerd + worker-bundler path -// can package local-directory custom tools on every CLI guest OS. The sync -// assertion covers npm install inside workerd, including the Windows TLS trust -// path fixed by f0365449, and the execution assertion proves the published tool -// is invoked, not just synced. -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; - -import { scenario } from "../src/scenario"; -import { Target } from "../src/services"; -import type { Identity, Target as TargetShape } from "../src/target"; - -const execFileAsync = promisify(execFile); - -const SSH_OPTS = [ - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "ConnectTimeout=15", - "-o", - "ServerAliveInterval=5", - "-o", - "LogLevel=ERROR", -] as const; - -type GuestOs = "macos" | "linux" | "windows"; - -interface SyncResponse { - readonly status: "published" | "up-to-date" | "failed"; - readonly tools: readonly string[]; -} - -interface ExecuteResponse { - readonly structured: unknown; - readonly text: string; -} - -const guestOs = (): GuestOs => { - const os = process.env.E2E_VM_OS; - if (os === "macos" || os === "linux" || os === "windows") return os; - throw new Error(`Unsupported E2E_VM_OS: ${os ?? ""}`); -}; - -const sshInvocation = (command: string): { command: string; args: ReadonlyArray } => { - const host = process.env.E2E_CLI_VM_HOST; - if (!host) throw new Error("E2E_CLI_VM_HOST is not set"); - const os = guestOs(); - const wrapped = - os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command; - const keyPath = process.env.E2E_CLI_SSH_KEY; - const user = os === "windows" ? "Administrator" : "admin"; - return keyPath - ? { command: "ssh", args: ["-i", keyPath, ...SSH_OPTS, `${user}@${host}`, wrapped] } - : { - command: process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass", - args: ["-p", "admin", "ssh", ...SSH_OPTS, `${user}@${host}`, wrapped], - }; -}; - -const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => { - const invocation = sshInvocation(command); - try { - const { stdout, stderr } = await execFileAsync(invocation.command, [...invocation.args], { - maxBuffer: 64 * 1024 * 1024, - }); - return { stdout, stderr, code: 0 }; - } catch (error) { - const err = error as { stdout?: string; stderr?: string; code?: number }; - return { - stdout: err.stdout ?? "", - stderr: err.stderr ?? "", - code: typeof err.code === "number" ? err.code : 1, - }; - } -}; - -const fixturePath = (slug: string): string => - guestOs() === "windows" - ? `C:/Users/Administrator/apps-packed-fixture-${slug}` - : `/tmp/apps-packed-fixture-${slug}`; - -const windowsFixtureCommand = (path: string): string => ` -$ErrorActionPreference = 'Stop' -Remove-Item -Recurse -Force '${path}' -ErrorAction SilentlyContinue -New-Item -ItemType Directory -Force -Path '${path}/tools' | Out-Null -Set-Content -NoNewline -Encoding UTF8 -Path '${path}/tools/greet.ts' -Value @' -${"import"} { z } from "zod"; -${"import"} { defineTool } from "executor:app"; - -const Input = z.object({ name: z.string() }); - -export default defineTool({ - description: "Greet", - input: Input, - async handler(input) { - return { greeting: "hello " + input.name }; - }, -}); -'@ -Set-Content -NoNewline -Encoding UTF8 -Path '${path}/package.json' -Value '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' -Set-Content -NoNewline -Encoding UTF8 -Path '${path}/bun.lock' -Value '' -`; - -const unixFixtureCommand = (path: string): string => ` -set -eu -rm -rf '${path}' -mkdir -p '${path}/tools' -cat >'${path}/tools/greet.ts' <<'EOF' -${"import"} { z } from "zod"; -${"import"} { defineTool } from "executor:app"; - -const Input = z.object({ name: z.string() }); - -export default defineTool({ - description: "Greet", - input: Input, - async handler(input) { - return { greeting: "hello " + input.name }; - }, -}); -EOF -printf '%s' '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' >'${path}/package.json' -: >'${path}/bun.lock' -`; - -const writeFixture = async (path: string): Promise => { - const result = await ssh( - guestOs() === "windows" ? windowsFixtureCommand(path) : unixFixtureCommand(path), - ); - expect( - result.code, - `fixture is written into the guest\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ).toBe(0); -}; - -const removeFixture = (path: string): Effect.Effect => - Effect.promise(() => - ssh( - guestOs() === "windows" - ? `Remove-Item -Recurse -Force '${path}' -ErrorAction SilentlyContinue` - : `rm -rf '${path}'`, - ).then(() => undefined), - ).pipe(Effect.ignore); - -const request = async ( - target: TargetShape, - identity: Identity, - path: string, - init: RequestInit = {}, - expectedStatus = 200, - timeoutMs = 30_000, -): Promise => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const headers = new Headers(init.headers); - for (const [name, value] of Object.entries(identity.headers ?? {})) headers.set(name, value); - if (init.body !== undefined && !headers.has("content-type")) { - headers.set("content-type", "application/json"); - } - const response = await fetch(new URL(path, target.baseUrl), { - ...init, - headers, - signal: controller.signal, - }); - const text = await response.text(); - expect(response.status, `${init.method ?? "GET"} ${path}: ${text}`).toBe(expectedStatus); - return (text.length > 0 ? JSON.parse(text) : null) as T; - } finally { - clearTimeout(timeout); - } -}; - -const postJson = ( - target: TargetShape, - identity: Identity, - path: string, - body?: unknown, - timeoutMs?: number, -): Promise => - request( - target, - identity, - path, - { - method: "POST", - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - }, - 200, - timeoutMs, - ); - -const deleteSource = (target: TargetShape, identity: Identity, slug: string): Effect.Effect => - Effect.promise(() => - request(target, identity, `/api/apps/sources/${slug}`, { method: "DELETE" }).then( - () => undefined, - ), - ).pipe(Effect.ignore); - -const syncAndInvoke = (input: { - readonly target: TargetShape; - readonly identity: Identity; - readonly slug: string; - readonly path: string; -}): Effect.Effect => - Effect.promise(async () => { - await postJson(input.target, input.identity, "/api/apps/sources", { - kind: "local-directory", - slug: input.slug, - app: input.slug, - path: input.path, - }); - - const sync = await postJson( - input.target, - input.identity, - `/api/apps/sources/${input.slug}/sync`, - undefined, - 180_000, - ); - expect(sync.status, "the packed binary bundled and published the tool").toBe("published"); - expect(sync.tools, "the packed binary bundled and published the tool").toContain("greet"); - - const execution = await postJson( - input.target, - input.identity, - "/api/executions", - { - code: `export default await tools["${input.slug}.org.published.greet"]({ name: "Ada" })`, - autoApprove: true, - }, - ); - expect( - JSON.stringify(execution.structured ?? execution.text), - "the published tool executes and returns the greeting", - ).toContain('"greeting":"hello Ada"'); - }); - -scenario( - "CLI packed binary · local-directory custom tool publishes and executes", - { timeout: 300_000 }, - Effect.gen(function* () { - const target = yield* Target; - const identity = yield* target.newIdentity(); - const slug = `packed-smoke-${crypto.randomUUID().slice(0, 8)}`; - const path = fixturePath(slug); - - yield* Effect.promise(() => writeFixture(path)).pipe( - Effect.andThen(syncAndInvoke({ target, identity, slug, path })), - Effect.ensuring( - Effect.all([deleteSource(target, identity, slug), removeFixture(path)], { - concurrency: "unbounded", - discard: true, - }), - ), - ); - }), -); diff --git a/e2e/desktop-vm/custom-tools-sidecar.test.ts b/e2e/desktop-vm/custom-tools-sidecar.test.ts deleted file mode 100644 index 244a017b9b..0000000000 --- a/e2e/desktop-vm/custom-tools-sidecar.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e SSH and raw HTTP assertions */ -// Proves the packaged desktop bundle's sidecar daemon can package and invoke a -// local-directory custom tool from inside the GUI guest. The API calls run over -// guest loopback so the daemon reads the same guest filesystem as the fixture. -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -import { expect, it } from "@effect/vitest"; -import { Effect } from "effect"; - -import { scenario } from "../src/scenario"; - -const execFileAsync = promisify(execFile); - -const SSH_OPTS = [ - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "ConnectTimeout=15", - "-o", - "ServerAliveInterval=5", - "-o", - "LogLevel=ERROR", -] as const; - -type DesktopOs = "macos" | "linux" | "windows"; - -interface CurlResult { - readonly status: number; - readonly body: T; - readonly text: string; -} - -interface SyncResponse { - readonly status: "published" | "up-to-date" | "failed"; - readonly tools: readonly string[]; -} - -interface ExecuteResponse { - readonly structured: unknown; - readonly text: string; -} - -const NAME = "Desktop sidecar · local-directory custom tool publishes and executes"; -const guestIp = process.env.E2E_DESKTOP_VM_IP; -const os: DesktopOs = - process.env.E2E_TARGET === "desktop-windows" - ? "windows" - : process.env.E2E_TARGET === "desktop-linux" - ? "linux" - : "macos"; - -const token = (): string => (os === "linux" ? "desktop-linux-e2e" : "desktop-macos-e2e"); - -const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => { - if (!guestIp) throw new Error("E2E_DESKTOP_VM_IP is not set"); - const wrapped = - os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command; - try { - const { stdout, stderr } = await execFileAsync( - process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass", - ["-p", "admin", "ssh", ...SSH_OPTS, `admin@${guestIp}`, wrapped], - { maxBuffer: 64 * 1024 * 1024 }, - ); - return { stdout, stderr, code: 0 }; - } catch (error) { - const err = error as { stdout?: string; stderr?: string; code?: number }; - return { - stdout: err.stdout ?? "", - stderr: err.stderr ?? "", - code: typeof err.code === "number" ? err.code : 1, - }; - } -}; - -const fixturePath = (slug: string): string => - os === "linux" - ? `/home/admin/apps-sidecar-fixture-${slug}` - : `/Users/admin/apps-sidecar-fixture-${slug}`; - -const writeFixtureCommand = (path: string): string => ` -set -eu -rm -rf '${path}' -mkdir -p '${path}/tools' -cat >'${path}/tools/greet.ts' <<'EOF' -${"import"} { z } from "zod"; -${"import"} { defineTool } from "executor:app"; - -const Input = z.object({ name: z.string() }); - -export default defineTool({ - description: "Greet", - input: Input, - async handler(input) { - return { greeting: "hello " + input.name }; - }, -}); -EOF -printf '%s' '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' >'${path}/package.json' -: >'${path}/bun.lock' -`; - -const writeFixture = async (path: string): Promise => { - const result = await ssh(writeFixtureCommand(path)); - expect( - result.code, - `fixture is written into the desktop guest\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ).toBe(0); -}; - -const removeFixture = (path: string): Effect.Effect => - Effect.promise(() => ssh(`rm -rf '${path}'`).then(() => undefined)).pipe(Effect.ignore); - -const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; - -const guestRequest = async ( - path: string, - init: { readonly method: "POST" | "DELETE"; readonly body?: unknown }, - timeoutSeconds = 30, -): Promise> => { - const body = init.body === undefined ? undefined : JSON.stringify(init.body); - const bodyFlag = body === undefined ? "" : ` --data-binary ${shellQuote(body)}`; - const command = [ - "curl", - "-sS", - "--max-time", - String(timeoutSeconds), - "-w", - shellQuote("\\n__HTTP_STATUS__:%{http_code}"), - "-X", - init.method, - "-H", - shellQuote(`Authorization: Bearer ${token()}`), - "-H", - shellQuote("content-type: application/json"), - bodyFlag, - shellQuote(`http://127.0.0.1:4789${path}`), - ].join(" "); - const result = await ssh(command); - expect( - result.code, - `${init.method} ${path} exits cleanly\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ).toBe(0); - const marker = "\n__HTTP_STATUS__:"; - const markerIndex = result.stdout.lastIndexOf(marker); - expect(markerIndex, `${init.method} ${path} printed an HTTP status`).toBeGreaterThanOrEqual(0); - const text = result.stdout.slice(0, markerIndex); - const status = Number(result.stdout.slice(markerIndex + marker.length).trim()); - return { status, body: (text.length > 0 ? JSON.parse(text) : null) as T, text }; -}; - -const expectGuestRequest = async ( - path: string, - init: { readonly method: "POST" | "DELETE"; readonly body?: unknown }, - timeoutSeconds?: number, -): Promise => { - const result = await guestRequest(path, init, timeoutSeconds); - expect(result.status, `${init.method} ${path}: ${result.text}`).toBe(200); - return result.body; -}; - -const deleteSource = (slug: string): Effect.Effect => - Effect.promise(() => - expectGuestRequest(`/api/apps/sources/${slug}`, { method: "DELETE" }).then(() => undefined), - ).pipe(Effect.ignore); - -const syncAndInvoke = (input: { - readonly slug: string; - readonly path: string; -}): Effect.Effect => - Effect.promise(async () => { - await expectGuestRequest("/api/apps/sources", { - method: "POST", - body: { - kind: "local-directory", - slug: input.slug, - app: input.slug, - path: input.path, - }, - }); - - const sync = await expectGuestRequest( - `/api/apps/sources/${input.slug}/sync`, - { method: "POST" }, - 180, - ); - expect(sync.status, "the desktop sidecar bundled and published the tool").toBe("published"); - expect(sync.tools, "the desktop sidecar bundled and published the tool").toContain("greet"); - - const execution = await expectGuestRequest("/api/executions", { - method: "POST", - body: { - code: `export default await tools["${input.slug}.org.published.greet"]({ name: "Ada" })`, - autoApprove: true, - }, - }); - expect( - JSON.stringify(execution.structured ?? execution.text), - "the published tool executes and returns the greeting", - ).toContain('"greeting":"hello Ada"'); - }); - -if (!guestIp) { - it.skip(`${NAME} (needs a desktop guest with E2E_DESKTOP_VM_IP)`, () => {}); -} else if (os === "windows") { - it.skip(`${NAME} (desktop-windows does not boot a sidecar daemon with a known token; use cli-windows)`, () => {}); -} else { - scenario( - "Desktop sidecar · local-directory custom tool publishes and executes", - { timeout: 300_000 }, - Effect.gen(function* () { - const slug = `sidecar-smoke-${crypto.randomUUID().slice(0, 8)}`; - const path = fixturePath(slug); - - yield* Effect.promise(() => writeFixture(path)).pipe( - Effect.andThen(syncAndInvoke({ slug, path })), - Effect.ensuring( - Effect.all([deleteSource(slug), removeFixture(path)], { - concurrency: "unbounded", - discard: true, - }), - ), - ); - }), - ); -} diff --git a/e2e/fixtures/custom-tools-git/custom-tools-shas.txt b/e2e/fixtures/custom-tools-git/custom-tools-shas.txt deleted file mode 100644 index ca359a139f..0000000000 --- a/e2e/fixtures/custom-tools-git/custom-tools-shas.txt +++ /dev/null @@ -1,3 +0,0 @@ -1f4b8f61f800b8164678f38c1c8c325e38baaf36 -252ca58f9bea3e7dacaae04b828c57a497a6233c -f8b60f186b4a637bb5e5527da2a29da0f13b8683 diff --git a/e2e/fixtures/custom-tools-git/custom-tools-v1.pack b/e2e/fixtures/custom-tools-git/custom-tools-v1.pack deleted file mode 100644 index 086e39354ce2b07494fbed55802bce1fbbc88f60..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1235 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 Z140f{W-+k+*(1>a0Dym-Qnmtx z$>!S2b|Dmj4ys?40(2&L3}e^xp#8#wkZN8YErdPlwISoTjF>)YB%Pir;tNxILEz0q zc-dMfk`9ej)VP<*9mM!uDTvOG&GX;qRGej6Ygd>kH>JFlMF~xr+Nc_xUW>Jjyyk!S zGp%^%y*aT1yGa6D~%Nk9{8wN);Nt#3OEsb#Y)2G%r~EIblsi(HO7od9^A zxAzZ^^1p6ya&B%c{V{cF?_~LFRBT-*mtDWU@X1?mmtBz?t+3F8t9cz$WYOw07(|sA zn!mo>@^f>p#z$$O-O{ENL19Y#co+cG45lzE2rek&Q}96}=m2-rqsBcPZVVq7#X|>1 zM_~kKGZ(z`P$d**?1C?s0x*gSAx#qh(KrvIQN!pFS7Sm=Il%>kMq}W(8g~=EDbJLL zaWO`Xo7Q^@aOHzo(A|Tp`LKyeFrd0iN&e}c|2E>{%Eye14xlh_z9Ed1Vzq*ca$WDu zU%Wh$2B#N9fBkd~qKix!9A8SmF-H@N)$ccpR#>aYLe5TC%&LC3*X{@ttX?&ypIZ?K z;%|52=dD$>D!z*I7Fu!-G(XM6STUlHaejJK!zzU;RfRT97{fnwgEEh&dali07oF*y z%4|__H~^vRm<)7CH)7Ap zE!!7%6_TOhfJ&Pqyr{pff}kXo23D7Br^3qaR{f#cnxC54#iE>lq)Tc(s=szR>2PQr zE@03^=sw$rkQ~7)MFgf{k(=HDiHjn0DYAB2=n=j%xhtqIWcSrOZTX^HBN8w>H%jk- zkps5r)D?p@+^rr~R&D-kohm~a>z(^({PTUe&jOVhwt5UN^FtfB4{h7qT+mtD(bT2L z-Wd%=6NFFfPJWSqqq5#L-}I-oeAIXFN-7aiqeWp}%53>Ng=2J_RPn&BMBq$8Qv^N4 zQF?+Dd?174?s>nb|2dsjFm9)xu+XH??4b$Cd%G*z!la;MmScWr&<0|2sGu*J6(~I- zCm;MGD{g_(36}{ALb{zvrfYfTV;%%Aef3SqsJ@_5gOqz$uO?%}L^%DT+25ai8y5#2 zGmlEN;TT3PwVf)TViY_KzS2W5G${G_uJL$!f~&-qlmd$QZ2g~xjL9$Bi0_W)m+uhy zdbQc59ZiPuzPB7m{G5RP32=>t5g%J71pR#5spM_C`cUzAI@%QIEnNCZ1hM6=T#7nJ zU<POBoBCb*aR>XCHXt81d%uQxT!@xwyLRgK(N z)CkT^(BI^wJZ^F0)AugmXT8A1S^uW6DO zB+h1Jz8jb$f1vu4c*@n=fx;M0QRcOApUXLkh9V4q+&N6sg96NOD94;>sOD{BcoN_&rhsy{RzRV;DhpLRL3N>M=pq z1tk<(K2D8sk*$}lxAK*>bIT^4e&{XfzW<3N!NxL!?^8|$mDiSXj^T?Z4|4W%+6iM1 zvmhH*-vSzpZO3l{Hdk*2TJE=VxWVdha3J#yq1bJOIW&i-svn~ z9Bi0OV+lNyi9v9wWTHkD5fcOlBID(KMIer4Ge&o7a<}B7QLCGq8by&7+el$D1}$>J z97_5Hi)!_=LG43xttS@KJSQ4wYS$M%K-D+*=0zWvRE+StanPkPUa~C?Irl#8QC}v7 z`OE0D>@e1(J0MpGe)<30lf3%2mJYzJ$e~r1jd#+&eV#GKQh?vR_dJjJU+$+(afaaw g2QLB2w { - const proc = Bun.spawnSync({ - cmd: ["git", ...args], - cwd, - stdin: stdin ? new TextEncoder().encode(stdin) : undefined, - }); - if (!proc.success) { - throw new Error( - `git ${args.join(" ")} failed\n${proc.stderr.toString()}\n${proc.stdout.toString()}`, - ); - } - return proc.stdout.toString().trim(); -}; - -const write = async (root: string, path: string, body: string) => { - await mkdir(dirname(join(root, path)), { recursive: true }); - await writeFile(join(root, path), body); -}; - -const packageJson = JSON.stringify({ dependencies: { effect: "4.0.0-beta.59", zod: "4.3.6" } }); -const executorJson = JSON.stringify({ description: "Custom tools e2e fixture" }); - -const echoTool = `import { z } from "zod"; -import { defineTool, integration } from "executor:app"; - -export default defineTool({ - description: "Echo a message through a projected app connection.", - integrations: { apps: integration("repo") }, - input: z.object({ message: z.string() }), - output: { - type: "object", - properties: { message: { type: "string" }, version: { type: "string" } }, - required: ["message", "version"], - }, - annotations: { readOnly: true }, - async handler(input) { - return { message: input.message, version: "v1" }; - }, -}); -`; - -const staticTool = `import { defineTool } from "executor:app"; - -export default defineTool({ - description: "Return a static fixture marker.", - input: { type: "object", properties: {} }, - output: { - type: "object", - properties: { ok: { type: "boolean" } }, - required: ["ok"], - }, - annotations: { readOnly: true }, - async handler() { - return { ok: true }; - }, -}); -`; - -const effectTool = `import { Effect } from "effect"; -import { defineTool } from "executor:app"; - -export default defineTool({ - description: "Return a marker produced through Effect.", - input: { type: "object", properties: {} }, - output: { - type: "object", - properties: { ok: { type: "boolean" }, dependency: { type: "string" } }, - required: ["ok", "dependency"], - }, - annotations: { readOnly: true }, - async handler() { - return Effect.runSync(Effect.succeed({ ok: true, dependency: "effect" })); - }, -}); -`; - -const extraTool = `import { defineTool } from "executor:app"; - -export default defineTool({ - description: "Return a second static fixture marker.", - input: { type: "object", properties: {} }, - output: { - type: "object", - properties: { added: { type: "boolean" } }, - required: ["added"], - }, - annotations: { readOnly: true }, - async handler() { - return { added: true }; - }, -}); -`; - -const badCollectTool = `import { defineTool } from "executor:app"; - -const input = { type: "object", properties: {} }; - -export default { - "bad-collect": defineTool({ - description: "Trigger a collect-stage fixture failure.", - input, - async handler() { - return { ok: true }; - }, - }), -}; -`; - -const commit = (root: string, message: string): string => { - run(root, ["add", "."]); - run(root, ["commit", "-q", "-m", message]); - return run(root, ["rev-parse", "HEAD"]); -}; - -const pack = async (root: string, sha: string, name: string) => { - const proc = Bun.spawnSync({ - cmd: ["git", "pack-objects", "--stdout", "--revs"], - cwd: root, - stdin: new TextEncoder().encode(`${sha}\n`), - }); - if (!proc.success) throw new Error(`git pack-objects failed: ${proc.stderr.toString()}`); - await writeFile(join(fixtureDir, name), proc.stdout); -}; - -const root = await mkdtemp(join(tmpdir(), "custom-tools-git-")); -try { - run(root, ["init", "-q"]); - run(root, ["config", "user.name", "Fixture Generator"]); - run(root, ["config", "user.email", "fixture@example.test"]); - - await write(root, "executor.json", executorJson); - await write(root, "package.json", packageJson); - await write(root, "bun.lock", ""); - await write(root, "tools/echo-tool.ts", echoTool); - await write(root, "tools/static-tool.ts", staticTool); - await write(root, "tools/effect-tool.ts", effectTool); - const sha1 = commit(root, "custom tools v1"); - - await write(root, "tools/extra-tool.ts", extraTool); - const sha2 = commit(root, "custom tools v2"); - - await write(root, "tools/bad-collect.ts", badCollectTool); - const sha3 = commit(root, "custom tools v3"); - - await writeFile(join(fixtureDir, "custom-tools-shas.txt"), `${sha1}\n${sha2}\n${sha3}\n`); - await pack(root, sha1, "custom-tools-v1.pack"); - await pack(root, sha2, "custom-tools-v2.pack"); - await pack(root, sha3, "custom-tools-v3.pack"); -} finally { - await rm(root, { recursive: true, force: true }); -} diff --git a/e2e/local/custom-tools-directory-picker.test.ts b/e2e/local/custom-tools-directory-picker.test.ts deleted file mode 100644 index 463c62ebb4..0000000000 --- a/e2e/local/custom-tools-directory-picker.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Local-only: custom tool local-directory sources can browse the user's own -// filesystem through the local app. The shared self-host target intentionally -// does not expose this source kind. -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; - -import { scenario } from "../src/scenario"; -import { Browser, Cli, RunDir, Target } from "../src/services"; -import { withLocalServer } from "./local-server"; - -scenario( - "Local custom tools · the directory picker selects a local source path", - { timeout: 180_000 }, - Effect.gen(function* () { - const cli = yield* Cli; - const browser = yield* Browser; - const target = yield* Target; - const runDir = yield* RunDir; - const identity = yield* target.newIdentity(); - const fixtureRoot = mkdtempSync(join(homedir(), ".executor-picker-e2e-")); - const fixtureDir = join(fixtureRoot, "picked-tools"); - mkdirSync(join(fixtureDir, "tools"), { recursive: true }); - writeFileSync( - join(fixtureDir, "package.json"), - JSON.stringify({ - name: "picked-tools", - version: "0.0.0", - private: true, - dependencies: { zod: "^4.1.13" }, - }), - ); - writeFileSync( - join(fixtureDir, "tools", "echo.ts"), - ` -import { z } from "zod"; -import { defineTool } from "executor:app"; - -export default defineTool({ - description: "Echo a picker message.", - input: z.object({ message: z.string() }), - handler(input) { - return { echoed: \`local-picker:\${input.message}\` }; - }, -}); -`, - ); - - yield* withLocalServer(cli, runDir, ({ url }) => - browser.session(identity, async ({ page, step }) => { - await step("Open custom tools from the local console", async () => { - const addSourceUrl = new URL(url); - addSourceUrl.pathname = "/integrations/add/apps"; - await page.goto(addSourceUrl.toString(), { - waitUntil: "domcontentloaded", - }); - await page.getByRole("heading", { name: "Add custom tools" }).waitFor({ - timeout: 60_000, - }); - }); - - await step("Browse to a local directory and select it", async () => { - await page.getByRole("button", { name: "Directory path" }).click(); - const pathInput = page.getByPlaceholder("/Users/me/tools"); - await pathInput.fill(fixtureRoot); - await page.getByRole("button", { name: "Browse" }).click(); - const dialog = page.getByRole("dialog", { name: "Choose directory" }); - await dialog - .getByRole("button", { name: /picked-tools/ }) - .getByText("tools", { exact: true }) - .waitFor({ timeout: 30_000 }); - await dialog.getByRole("button", { name: "picked-tools" }).click(); - await dialog.getByText("1 tool found:").waitFor({ timeout: 30_000 }); - await dialog.getByText("echo", { exact: true }).waitFor({ timeout: 30_000 }); - await dialog.getByRole("button", { name: "Select" }).click(); - expect(await pathInput.inputValue()).toBe(fixtureDir); - }); - - await step("Sync the picked directory as a custom tools source", async () => { - await page.getByRole("button", { name: "Sync source" }).click(); - await page.waitForURL(/\/integrations\/picked-tools(?:\?|$)/, { timeout: 90_000 }); - await page.getByLabel("Source").getByText("1 tool").waitFor({ timeout: 90_000 }); - }); - - await step("Run the published local tool from the console", async () => { - await page.getByRole("tab", { name: "Tools" }).click(); - await page.getByRole("button", { name: /picked-tools\s+1/ }).click(); - await page.getByRole("button", { name: "echo", exact: true }).click(); - await page.getByRole("tab", { name: "Run" }).click(); - await page.getByLabel("message").fill("from directory picker"); - await page.getByRole("button", { name: "Run", exact: true }).click(); - await page - .locator("pre") - .filter({ hasText: "local-picker:from directory picker" }) - .last() - .waitFor({ timeout: 90_000 }); - }); - - await step("Remove the local directory source", async () => { - await page.goto(new URL("/integrations/picked-tools?tab=source", url).toString(), { - waitUntil: "networkidle", - }); - await page.getByRole("button", { name: "Remove" }).click(); - await page.getByRole("button", { name: "Remove source" }).click(); - await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 }); - }); - }), - ).pipe( - Effect.ensuring(Effect.sync(() => rmSync(fixtureRoot, { recursive: true, force: true }))), - ); - }), -); diff --git a/e2e/scenarios/custom-tools.test.ts b/e2e/scenarios/custom-tools.test.ts deleted file mode 100644 index 2c7a351c08..0000000000 --- a/e2e/scenarios/custom-tools.test.ts +++ /dev/null @@ -1,534 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e fixture server and raw HTTP assertions */ -import { readFile } from "node:fs/promises"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { join } from "node:path"; - -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; - -import { scenario } from "../src/scenario"; -import { Api, Browser, Target } from "../src/services"; -import type { Identity, Target as TargetShape } from "../src/target"; -import type { BrowserSurface } from "../src/surfaces/browser"; - -const textEncoder = new TextEncoder(); -const FIXTURE_GIT_TOKEN = "fixture-token"; -const EXPECTED_GIT_AUTHORIZATION = `Basic ${btoa(`git:${FIXTURE_GIT_TOKEN}`)}`; - -const concat = (parts: readonly Uint8Array[]): Uint8Array => { - const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); - let offset = 0; - for (const part of parts) { - out.set(part, offset); - offset += part.length; - } - return out; -}; - -const pktLine = (payload: string | Uint8Array): Uint8Array => { - const body = typeof payload === "string" ? textEncoder.encode(payload) : payload; - const header = textEncoder.encode((body.length + 4).toString(16).padStart(4, "0")); - return concat([header, body]); -}; - -const FLUSH = textEncoder.encode("0000"); - -const sideBand = (bytes: Uint8Array): Uint8Array => { - const chunks: Uint8Array[] = [pktLine("NAK\n")]; - for (let offset = 0; offset < bytes.length; offset += 60_000) { - const chunk = bytes.subarray(offset, offset + 60_000); - const payload = new Uint8Array(chunk.length + 1); - payload[0] = 1; - payload.set(chunk, 1); - chunks.push(pktLine(payload)); - } - chunks.push(FLUSH); - return concat(chunks); -}; - -const advertisement = (sha: string): Uint8Array => - concat([ - pktLine("# service=git-upload-pack\n"), - FLUSH, - pktLine( - `${sha} HEAD\0symref=HEAD:refs/heads/main multi_ack side-band-64k thin-pack ofs-delta\n`, - ), - pktLine(`${sha} refs/heads/main\n`), - FLUSH, - ]); - -const readFixture = async () => { - const dir = join(import.meta.dirname, "..", "fixtures", "custom-tools-git"); - const [shas, pack1, pack2, pack3] = await Promise.all([ - readFile(join(dir, "custom-tools-shas.txt"), "utf8"), - readFile(join(dir, "custom-tools-v1.pack")), - readFile(join(dir, "custom-tools-v2.pack")), - readFile(join(dir, "custom-tools-v3.pack")), - ]); - const [sha1, sha2, sha3] = shas.trim().split("\n"); - return { sha1: sha1!, sha2: sha2!, sha3: sha3!, pack1, pack2, pack3 }; -}; - -const fixtureGitServer = Effect.acquireRelease( - Effect.promise(async () => { - const fixture = await readFixture(); - let current = { sha: fixture.sha1, pack: new Uint8Array(fixture.pack1) }; - let packRequests = 0; - const server = createServer((request: IncomingMessage, response: ServerResponse) => { - if ( - (request.url === "/repo.git/info/refs?service=git-upload-pack" || - request.url === "/repo.git/git-upload-pack") && - request.headers.authorization !== EXPECTED_GIT_AUTHORIZATION - ) { - response.writeHead(401, { - "content-type": "text/plain", - "www-authenticate": 'Basic realm="custom-tools-fixture"', - }); - response.end("authentication required"); - return; - } - if (request.url === "/repo.git/info/refs?service=git-upload-pack") { - response.writeHead(200, { - "content-type": "application/x-git-upload-pack-advertisement", - }); - response.end(advertisement(current.sha)); - return; - } - if (request.url === "/repo.git/git-upload-pack" && request.method === "POST") { - packRequests += 1; - response.writeHead(200, { "content-type": "application/x-git-upload-pack-result" }); - response.end(sideBand(current.pack)); - return; - } - response.writeHead(404, { "content-type": "text/plain" }); - response.end("not found"); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("fixture server did not bind"); - return { - url: `http://127.0.0.1:${address.port}/repo.git`, - advance: () => { - current = { sha: fixture.sha2, pack: new Uint8Array(fixture.pack2) }; - }, - failCollect: () => { - current = { sha: fixture.sha3, pack: new Uint8Array(fixture.pack3) }; - }, - restoreGood: () => { - current = { sha: fixture.sha2, pack: new Uint8Array(fixture.pack2) }; - }, - packRequests: () => packRequests, - close: () => new Promise((resolve) => server.close(() => resolve())), - }; - }), - (server) => Effect.promise(() => server.close()).pipe(Effect.ignore), -); - -interface ToolRow { - readonly address: string; - readonly name: string; - readonly integration: string; -} - -interface ExecuteResponse { - readonly status: "completed" | "paused"; - readonly text: string; - readonly structured: unknown; - readonly isError?: boolean; -} - -const authHeaders = async ( - target: TargetShape, - identity: Identity, -): Promise> => { - if (identity.headers) return identity.headers; - const credentials = identity.credentials; - if (!credentials) throw new Error(`identity ${identity.label} has no credentials`); - const response = await fetch(new URL("/api/auth/sign-in/email", target.baseUrl), { - method: "POST", - headers: { "content-type": "application/json", origin: new URL(target.baseUrl).origin }, - body: JSON.stringify(credentials), - redirect: "manual", - }); - const cookie = (response.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; "); - if (!cookie) throw new Error(`sign-in set no cookie (${response.status})`); - return { cookie }; -}; - -const request = async ( - target: TargetShape, - identity: Identity, - path: string, - init: RequestInit = {}, - expectedStatus = 200, -): Promise<{ readonly body: T; readonly text: string }> => { - const headers = new Headers(init.headers); - headers.set("origin", new URL(target.baseUrl).origin); - for (const [name, value] of Object.entries(await authHeaders(target, identity))) { - headers.set(name, value); - } - if (init.body !== undefined && !headers.has("content-type")) { - headers.set("content-type", "application/json"); - } - const response = await fetch(new URL(path, target.baseUrl), { ...init, headers }); - const text = await response.text(); - expect(response.status, `${init.method ?? "GET"} ${path}: ${text}`).toBe(expectedStatus); - return { body: (text.length > 0 ? JSON.parse(text) : null) as T, text }; -}; - -const postJson = ( - target: TargetShape, - identity: Identity, - path: string, - body?: unknown, - expectedStatus = 200, -): Promise<{ readonly body: T; readonly text: string }> => - request( - target, - identity, - path, - { - method: "POST", - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - }, - expectedStatus, - ); - -const deletePath = ( - target: TargetShape, - identity: Identity, - path: string, -): Promise<{ readonly body: { readonly removed?: boolean }; readonly text: string }> => - request(target, identity, path, { method: "DELETE" }); - -const execute = (target: TargetShape, identity: Identity, code: string): Promise => - postJson(target, identity, "/api/executions", { - code, - autoApprove: true, - }).then((response) => response.body); - -const addSourceThroughConsole = (input: { - readonly target: TargetShape; - readonly browser: BrowserSurface; - readonly identity: Identity; - readonly sourceUrl: string; -}) => - input.browser.session(input.identity, async ({ page, step }) => { - await step("Open the integrations page", async () => { - await page.goto(new URL("/integrations", input.target.baseUrl).toString(), { - waitUntil: "networkidle", - }); - }); - - await step("Detect the Git repository as custom tools", async () => { - await page.getByRole("button", { name: "Connect", exact: true }).click(); - const dialog = page.getByRole("dialog"); - await dialog.getByRole("textbox").fill(input.sourceUrl); - await dialog.getByRole("button", { name: "Detect" }).click(); - await page.getByRole("heading", { name: "Add custom tools" }).waitFor(); - }); - - await step("Sync the custom tools source", async () => { - await page.locator('input[type="password"]').fill(FIXTURE_GIT_TOKEN); - await page.getByRole("button", { name: "Sync source" }).click(); - await page.waitForURL(/\/integrations\/repo(?:\?|$)/, { timeout: 90_000 }); - await page.getByLabel("Source").getByText("3 tools").waitFor({ timeout: 90_000 }); - await page.getByRole("link", { name: "repo" }).waitFor({ timeout: 90_000 }); - await page.getByRole("tab", { name: "Tools" }).click(); - await page.getByRole("button", { name: /repo\s+3/ }).click(); - await page.getByRole("button", { name: "echo-tool", exact: true }).click(); - await page.getByRole("tab", { name: "Run" }).click(); - expect(await page.getByLabel("Connection").count()).toBe(0); - await page.getByLabel("message").fill("from run tab"); - await page.getByLabel("apps").selectOption("0"); - await page.getByRole("button", { name: "Run", exact: true }).click(); - await page - .locator("pre") - .filter({ hasText: "from run tab" }) - .filter({ hasText: "v1" }) - .last() - .waitFor({ timeout: 90_000 }); - }); - }); - -const assertBadTokenFailureThroughConsole = (input: { - readonly target: TargetShape; - readonly browser: BrowserSurface; - readonly identity: Identity; - readonly sourceUrl: string; -}) => - input.browser.session(input.identity, async ({ page, step }) => { - await step("Try to sync the custom tools source with a bad token", async () => { - await page.goto(new URL("/integrations", input.target.baseUrl).toString(), { - waitUntil: "networkidle", - }); - await page.getByRole("button", { name: "Connect", exact: true }).click(); - const dialog = page.getByRole("dialog"); - await dialog.getByRole("textbox").fill(input.sourceUrl); - await dialog.getByRole("button", { name: "Detect" }).click(); - await page.getByRole("heading", { name: "Add custom tools" }).waitFor(); - await page.locator('input[type="password"]').fill("wrong-token"); - const syncResponse = page.waitForResponse( - (response) => - response.url().includes("/api/apps/sources/repo/sync") && response.status() === 200, - { timeout: 90_000 }, - ); - await page.getByRole("button", { name: "Sync source" }).click(); - await syncResponse; - }); - - await step("See the failed sync on the source panel", async () => { - await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), { - waitUntil: "networkidle", - }); - await page.getByText("Last sync failed").waitFor({ timeout: 90_000 }); - await page.getByText("source: info/refs 401").waitFor({ timeout: 90_000 }); - }); - - await step("Remove the failed source before retrying with the good token", async () => { - await page.getByRole("button", { name: "Remove" }).click(); - await page.getByRole("button", { name: "Remove source" }).click(); - await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 }); - }); - }); - -const syncSourceInConsole = (input: { - readonly target: TargetShape; - readonly browser: BrowserSurface; - readonly identity: Identity; - readonly expectedNotice: string; - readonly expectedToolCount: string; -}) => - input.browser.session(input.identity, async ({ page, step }) => { - await step(`Sync custom tools and see ${input.expectedNotice}`, async () => { - await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), { - waitUntil: "networkidle", - }); - await page.getByRole("button", { name: "Sync" }).click(); - await page.getByText(input.expectedNotice).waitFor({ timeout: 90_000 }); - await page - .locator("p") - .filter({ hasText: new RegExp(`^${input.expectedToolCount}$`) }) - .waitFor({ timeout: 90_000 }); - }); - }); - -const syncCollectFailureInConsole = (input: { - readonly target: TargetShape; - readonly browser: BrowserSurface; - readonly identity: Identity; -}) => - input.browser.session(input.identity, async ({ page, step }) => { - await step("Sync custom tools and see the collect diagnostic", async () => { - await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), { - waitUntil: "networkidle", - }); - await page.getByRole("button", { name: "Sync" }).click(); - await page.getByText("Sync failed.").waitFor({ timeout: 90_000 }); - await page - .locator("p") - .filter({ hasText: "collect: record export key" }) - .first() - .waitFor({ timeout: 90_000 }); - await page - .locator("p") - .filter({ hasText: /^0 tools$/ }) - .waitFor({ timeout: 90_000 }); - }); - }); - -const removeSourceThroughConsole = (input: { - readonly target: TargetShape; - readonly browser: BrowserSurface; - readonly identity: Identity; -}) => - input.browser.session(input.identity, async ({ page, step }) => { - await step("Remove the custom tools source", async () => { - await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), { - waitUntil: "networkidle", - }); - await page.getByRole("button", { name: "Remove" }).click(); - await page.getByRole("button", { name: "Remove source" }).click(); - await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 }); - }); - }); - -scenario( - "Custom tools · Git source syncs, invokes, refreshes, and removes", - { timeout: 300_000 }, - Effect.scoped( - Effect.gen(function* () { - const target = yield* Target; - const browser = yield* Browser; - yield* Api; - const identity = yield* target.newIdentity(); - const git = yield* fixtureGitServer; - - yield* Effect.ensuring( - Effect.gen(function* () { - const unauthorized = yield* Effect.promise(() => - fetch(new URL("/api/apps/sources/custom-tools/sync", target.baseUrl), { - method: "POST", - }), - ); - expect(unauthorized.status, "sync requires authentication").toBe( - target.name === "cloud" ? 403 : 401, - ); - yield* Effect.promise(() => unauthorized.text()); - - const missingGitAuth = yield* Effect.promise(() => - fetch(`${git.url}/info/refs?service=git-upload-pack`), - ); - expect(missingGitAuth.status, "fixture git server requires auth").toBe(401); - yield* Effect.promise(() => missingGitAuth.text()); - - yield* assertBadTokenFailureThroughConsole({ - target, - browser, - identity, - sourceUrl: git.url, - }); - expect(git.packRequests(), "bad token fails before fetching a pack").toBe(0); - - yield* addSourceThroughConsole({ target, browser, identity, sourceUrl: git.url }); - - const sources = yield* Effect.promise(() => - request<{ - readonly sources: readonly { - readonly slug: string; - readonly app: string; - readonly config: { readonly kind: string; readonly url?: string }; - }[]; - }>(target, identity, "/api/apps/sources"), - ); - expect(sources.text).not.toContain("fixture-token"); - const source = sources.body.sources.find((item) => item.app === "repo"); - expect(source).toMatchObject({ - app: "repo", - config: { kind: "git", url: git.url }, - }); - - const tools = yield* Effect.promise(() => - request(target, identity, "/api/tools?integration=repo"), - ); - expect(tools.body.map((tool) => tool.name).sort()).toEqual([ - "echo-tool", - "effect-tool", - "static-tool", - ]); - const echoTool = tools.body.find((tool) => tool.name === "echo-tool"); - expect(echoTool).toBeDefined(); - - const schema = yield* Effect.promise(() => - request<{ - readonly inputSchema: { - readonly properties?: Record< - string, - { - readonly type?: string; - readonly enum?: readonly string[]; - readonly default?: unknown; - } - >; - readonly required?: readonly string[]; - }; - }>( - target, - identity, - `/api/tools/schema?address=${encodeURIComponent(echoTool!.address)}`, - ), - ); - expect(schema.body.inputSchema.properties?.apps).toMatchObject({ - enum: ["tools.repo.org.published"], - default: "tools.repo.org.published", - }); - expect(schema.body.inputSchema.properties?.message).toMatchObject({ type: "string" }); - expect(schema.body.inputSchema.required ?? []).toContain("message"); - expect(schema.body.inputSchema.required ?? []).not.toContain("apps"); - - const invoked = yield* Effect.promise(() => - execute( - target, - identity, - `return await tools["repo.org.published.echo-tool"]({ message: "hello", apps: "tools.repo.org.published" });`, - ), - ); - expect(invoked.status, invoked.text).toBe("completed"); - expect(invoked.isError, invoked.text).toBe(false); - expect(JSON.stringify(invoked.structured)).toContain("hello"); - expect(JSON.stringify(invoked.structured)).toContain("v1"); - - const effectInvoked = yield* Effect.promise(() => - execute(target, identity, `return await tools["repo.org.published.effect-tool"]({});`), - ); - expect(effectInvoked.status, effectInvoked.text).toBe("completed"); - expect(effectInvoked.isError, effectInvoked.text).toBe(false); - expect(effectInvoked.structured).toMatchObject({ - result: { ok: true, data: { ok: true, dependency: "effect" } }, - }); - - expect(git.packRequests(), "initial publish fetched one pack").toBe(1); - git.advance(); - yield* syncSourceInConsole({ - target, - browser, - identity, - expectedNotice: "Added: extra-tool", - expectedToolCount: "4 tools", - }); - - yield* syncSourceInConsole({ - target, - browser, - identity, - expectedNotice: "Already up to date.", - expectedToolCount: "4 tools", - }); - - git.failCollect(); - yield* syncCollectFailureInConsole({ target, browser, identity }); - - const afterFailedCollect = yield* Effect.promise(() => - request(target, identity, "/api/tools?integration=repo"), - ); - expect(afterFailedCollect.body.map((tool) => tool.name).sort()).toEqual([ - "echo-tool", - "effect-tool", - "extra-tool", - "static-tool", - ]); - - git.restoreGood(); - yield* syncSourceInConsole({ - target, - browser, - identity, - expectedNotice: "Already up to date.", - expectedToolCount: "4 tools", - }); - - yield* removeSourceThroughConsole({ target, browser, identity }); - - const afterRemove = yield* Effect.promise(() => - request(target, identity, "/api/tools?integration=repo"), - ); - expect(afterRemove.body).toEqual([]); - }), - Effect.promise(async () => { - const sources = await request<{ - readonly sources: readonly { readonly slug: string; readonly app: string }[]; - }>(target, identity, "/api/apps/sources").catch(() => ({ body: { sources: [] } })); - for (const source of sources.body.sources) { - if (source.app === "repo") { - await deletePath( - target, - identity, - `/api/apps/sources/${encodeURIComponent(source.slug)}`, - ).catch(() => undefined); - } - } - }).pipe(Effect.ignore), - ); - }), - ), -); diff --git a/package.json b/package.json index a703f82ce1..bf9fa84ba8 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "release:publish:packages:prepare": "bun run scripts/publish-packages.ts --prepare-only", "release:smoke:packages": "bun run scripts/smoke-test-packed.ts", "clean": "bun run scripts/clean.ts", - "prepare": "effect-language-service patch && effect-tsgo patch && bun run --cwd packages/core/vite-plugin build:bundle && bun run --cwd packages/plugins/apps build:bundle && bun run --cwd packages/react build" + "prepare": "effect-language-service patch && effect-tsgo patch && bun run --cwd packages/core/vite-plugin build:bundle && bun run --cwd packages/react build" }, "dependencies": {}, "devDependencies": { diff --git a/packages/plugins/apps/CHANGELOG.md b/packages/plugins/apps/CHANGELOG.md deleted file mode 100644 index 1ae4950b79..0000000000 --- a/packages/plugins/apps/CHANGELOG.md +++ /dev/null @@ -1,61 +0,0 @@ -# @executor-js/plugin-apps - -## 0.1.6 - -### Patch Changes - -- Updated dependencies [[`1b9b1f1`](https://github.com/UsefulSoftwareCo/executor/commit/1b9b1f10313834a625a411169ebf83f6181589df)]: - - @executor-js/sdk@1.5.35 - - @executor-js/api@1.4.55 - - @executor-js/react@1.4.55 - - @executor-js/runtime-workerd-subprocess@0.0.7 - -## 0.1.5 - -### Patch Changes - -- Updated dependencies [[`e2712db`](https://github.com/UsefulSoftwareCo/executor/commit/e2712dbff98145c5c340832ffbdcb21113b9dd78), [`7207347`](https://github.com/UsefulSoftwareCo/executor/commit/720734756a70b1b4f1564bdf82dc4118e5de2b76), [`0c4e9b4`](https://github.com/UsefulSoftwareCo/executor/commit/0c4e9b49fecb35ad71c92a464c3ea01131ff9d6f)]: - - @executor-js/sdk@1.5.34 - - @executor-js/api@1.4.54 - - @executor-js/react@1.4.54 - - @executor-js/runtime-workerd-subprocess@0.0.6 - -## 0.1.4 - -### Patch Changes - -- Updated dependencies []: - - @executor-js/sdk@1.5.33 - - @executor-js/api@1.4.53 - - @executor-js/react@1.4.53 - - @executor-js/runtime-workerd-subprocess@0.0.5 - -## 0.1.3 - -### Patch Changes - -- Updated dependencies []: - - @executor-js/sdk@1.5.32 - - @executor-js/api@1.4.52 - - @executor-js/react@1.4.52 - - @executor-js/runtime-workerd-subprocess@0.0.4 - -## 0.1.2 - -### Patch Changes - -- Updated dependencies []: - - @executor-js/sdk@1.5.31 - - @executor-js/api@1.4.51 - - @executor-js/react@1.4.51 - - @executor-js/runtime-workerd-subprocess@0.0.3 - -## 0.1.1 - -### Patch Changes - -- Updated dependencies []: - - @executor-js/sdk@1.5.30 - - @executor-js/api@1.4.50 - - @executor-js/react@1.4.50 - - @executor-js/runtime-workerd-subprocess@0.0.2 diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json deleted file mode 100644 index c75caccced..0000000000 --- a/packages/plugins/apps/package.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "name": "@executor-js/plugin-apps", - "version": "0.1.6", - "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/apps", - "bugs": { - "url": "https://github.com/UsefulSoftwareCo/executor/issues" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/UsefulSoftwareCo/executor.git", - "directory": "packages/plugins/apps" - }, - "files": [ - "dist" - ], - "type": "module", - "exports": { - ".": "./src/index.ts", - "./api": "./src/api.ts", - "./authoring": "./src/authoring.ts", - "./cloud": "./src/cloud.ts", - "./client": "./src/react/plugin-client.tsx", - "./react": "./src/react/index.ts", - "./selfhost": "./src/selfhost.ts", - "./testing": "./src/testing/index.ts", - "./vite": { - "types": "./src/vite.ts", - "bun": "./src/vite.ts", - "default": "./dist/vite.js" - } - }, - "scripts": { - "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", - "build:bundle": "tsup", - "typecheck": "tsgo --noEmit", - "test": "vitest run", - "test:watch": "vitest", - "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" - }, - "dependencies": { - "@cloudflare/worker-bundler": "0.2.1", - "@executor-js/api": "workspace:*", - "@executor-js/runtime-workerd-subprocess": "workspace:*", - "@executor-js/sdk": "workspace:*", - "esbuild": "^0.27.3", - "zod": "4.3.6" - }, - "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.15.0", - "@effect/atom-react": "catalog:", - "@effect/vitest": "catalog:", - "@executor-js/react": "workspace:*", - "@types/node": "catalog:", - "@types/react": "catalog:", - "bun-types": "catalog:", - "effect": "catalog:", - "react": "catalog:", - "tsup": "catalog:", - "vite": "catalog:", - "vitest": "catalog:" - }, - "peerDependencies": { - "@effect/atom-react": "catalog:", - "@executor-js/react": "workspace:*", - "@tanstack/react-router": "catalog:", - "effect": "catalog:", - "react": "catalog:" - }, - "peerDependenciesMeta": { - "@effect/atom-react": { - "optional": true - }, - "@executor-js/react": { - "optional": true - }, - "@tanstack/react-router": { - "optional": true - }, - "react": { - "optional": true - } - } -} diff --git a/packages/plugins/apps/src/api.ts b/packages/plugins/apps/src/api.ts deleted file mode 100644 index 47e23be126..0000000000 --- a/packages/plugins/apps/src/api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { definePlugin } from "@executor-js/sdk/core"; - -import { makeAppsPlugin, type AppsPluginOptions } from "./plugin/apps-plugin"; -import { AppsExtensionService, appsHandlersForSourceKinds } from "./plugin/handlers"; -import { appsGroupForSourceKinds } from "./plugin/routes"; - -export { AppsGroup } from "./plugin/routes"; -export { AppsHandlers, AppsExtensionService } from "./plugin/handlers"; -export type { PublishInput, PublishResult } from "./pipeline/publish"; -export { publish } from "./pipeline/publish"; -export { makeAppsPlugin, appsPlugin } from "./plugin/apps-plugin"; - -export const appsHttpPlugin = definePlugin((options?: AppsPluginOptions) => ({ - ...makeAppsPlugin(options), - routes: () => appsGroupForSourceKinds(options?.sourceKinds), - handlers: () => appsHandlersForSourceKinds(options?.sourceKinds), - extensionService: AppsExtensionService, -})); diff --git a/packages/plugins/apps/src/authoring.test.ts b/packages/plugins/apps/src/authoring.test.ts deleted file mode 100644 index afb739cb2a..0000000000 --- a/packages/plugins/apps/src/authoring.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { integration } from "./authoring"; - -describe("authoring integration builder", () => { - it("returns immutable fluent declarations", () => { - const base = integration("gmail"); - const many = base.array().describe("Inbox accounts"); - - expect(Object.isFrozen(base)).toBe(true); - expect(Object.isFrozen(many)).toBe(true); - expect(base).toMatchObject({ - kind: "integration", - slug: "gmail", - mode: "one", - }); - expect(many).toMatchObject({ - kind: "integration", - slug: "gmail", - mode: "many", - description: "Inbox accounts", - }); - expect(base).not.toBe(many); - }); -}); diff --git a/packages/plugins/apps/src/authoring.ts b/packages/plugins/apps/src/authoring.ts deleted file mode 100644 index e158c66399..0000000000 --- a/packages/plugins/apps/src/authoring.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { StandardSchemaV1 } from "./standard-schema"; - -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; -export interface JsonObject { - readonly [key: string]: JsonValue; -} - -export type ToolSchema = StandardSchemaV1 | JsonObject; - -export type AppIntegrationClient = { - readonly [key: string]: AppIntegrationClient; -} & ((...args: readonly unknown[]) => Promise); - -export type IntegrationMode = "one" | "many"; - -export interface IntegrationDeclaration< - Slug extends string = string, - Mode extends IntegrationMode = IntegrationMode, -> { - readonly kind: "integration"; - readonly slug: Slug; - readonly mode: Mode; - readonly description?: string; - /** - * Ask the caller to choose a set of connections. Future author-chosen binding modes should not - * project optional caller fields: optional fields are suggestions, and model callers under-fill - * them. - */ - readonly array: () => IntegrationDeclaration; - readonly describe: (text: string) => IntegrationDeclaration; -} - -export type IntegrationClients = - TIntegrations extends Readonly> - ? { - readonly [K in keyof TIntegrations]: TIntegrations[K] extends { - readonly mode: "many"; - } - ? readonly AppIntegrationClient[] - : AppIntegrationClient; - } - : Record; - -export type IntegrationDeclarations = Readonly>; - -interface IntegrationDeclarationState< - Slug extends string = string, - Mode extends IntegrationMode = IntegrationMode, -> { - readonly slug: Slug; - readonly mode: Mode; - readonly description?: string; -} - -export interface SerializedIntegrationDeclaration { - readonly slug: string; - readonly mode: IntegrationMode; - readonly description?: string; -} - -const makeIntegrationDeclaration = ( - state: IntegrationDeclarationState, -): IntegrationDeclaration => { - const declaration = { - kind: "integration" as const, - slug: state.slug, - mode: state.mode, - ...(state.description !== undefined ? { description: state.description } : {}), - array: () => makeIntegrationDeclaration({ ...state, mode: "many" as const }), - describe: (text: string) => makeIntegrationDeclaration({ ...state, description: text }), - }; - return Object.freeze(declaration); -}; - -type InferToolInput = TSchema extends StandardSchemaV1 - ? StandardSchemaV1.InferOutput - : unknown; - -type InferToolOutput = TSchema extends StandardSchemaV1 - ? StandardSchemaV1.InferOutput - : unknown; - -export interface DefineToolOptions< - TInputSchema extends ToolSchema, - TOutputSchema extends ToolSchema | undefined = undefined, - TIntegrations extends IntegrationDeclarations | undefined = undefined, -> { - readonly description: string; - readonly integrations?: TIntegrations; - readonly input: TInputSchema; - readonly output?: TOutputSchema; - readonly annotations?: { - readonly readOnly?: boolean; - readonly destructive?: boolean; - readonly requiresApproval?: boolean; - }; - readonly handler: ( - input: InferToolInput, - context: IntegrationClients, - ) => - | Promise : unknown> - | (TOutputSchema extends ToolSchema ? InferToolOutput : unknown); -} - -export interface DefinedTool< - TInputSchema extends ToolSchema = ToolSchema, - TOutputSchema extends ToolSchema | undefined = ToolSchema | undefined, - TIntegrations extends IntegrationDeclarations | undefined = IntegrationDeclarations | undefined, -> extends DefineToolOptions { - readonly "~executorAppTool": true; -} - -export const integration = (slug: Slug): IntegrationDeclaration => - makeIntegrationDeclaration({ slug, mode: "one" as const }); - -export const defineTool = < - TInputSchema extends ToolSchema, - TOutputSchema extends ToolSchema | undefined = undefined, - TIntegrations extends IntegrationDeclarations | undefined = undefined, ->( - definition: DefineToolOptions, -): DefinedTool => ({ - ...definition, - "~executorAppTool": true, -}); diff --git a/packages/plugins/apps/src/cloud.ts b/packages/plugins/apps/src/cloud.ts deleted file mode 100644 index c07e5d3f6c..0000000000 --- a/packages/plugins/apps/src/cloud.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { makeDynamicWorkerAppToolExecutor } from "./executor/dynamic-worker-app-tool-executor"; -export { makeDynamicWorkerBundlerBackend } from "./pipeline/dynamic-worker-bundler"; -export { WORKER_BUNDLER_VERSION } from "./pipeline/worker-bundler-version"; diff --git a/packages/plugins/apps/src/cloudflare-workers-test.d.ts b/packages/plugins/apps/src/cloudflare-workers-test.d.ts deleted file mode 100644 index 55d5929c92..0000000000 --- a/packages/plugins/apps/src/cloudflare-workers-test.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module "cloudflare:workers" { - export const env: unknown; -} diff --git a/packages/plugins/apps/src/executor/app-tool-executor.conformance.test.ts b/packages/plugins/apps/src/executor/app-tool-executor.conformance.test.ts deleted file mode 100644 index e8154029e6..0000000000 --- a/packages/plugins/apps/src/executor/app-tool-executor.conformance.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, it } from "@effect/vitest"; - -import { makeInProcessAppToolExecutor } from "./app-tool-executor"; -import { isWorkerdAvailable, makeWorkerdAppToolExecutor } from "./workerd-app-tool-executor"; -import { appToolExecutorConformance } from "../testing/conformance"; - -appToolExecutorConformance("in-process", makeInProcessAppToolExecutor); - -if (isWorkerdAvailable()) { - appToolExecutorConformance("workerd", makeWorkerdAppToolExecutor); -} else { - describe("workerd app tool executor conformance", () => { - it.skip("skipped because the workerd binary is unavailable on this platform", () => {}); - }); -} diff --git a/packages/plugins/apps/src/executor/app-tool-executor.test.ts b/packages/plugins/apps/src/executor/app-tool-executor.test.ts deleted file mode 100644 index 4ebe10f1e0..0000000000 --- a/packages/plugins/apps/src/executor/app-tool-executor.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; - -import { bundleEntry } from "../pipeline/bundle"; -import { makeInProcessAppToolExecutor } from "./app-tool-executor"; - -const bytes = (value: string): string => value; - -const bundle = (source: string) => - bundleEntry({ - files: new Map([["tools/sync-deals.ts", bytes(source)]]), - entry: "tools/sync-deals.ts", - }); - -describe("in-process app tool executor", () => { - it.effect("detects integration declarations and merges projected input fields", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool, integration } from "executor:app"; - export default defineTool({ - description: "Refresh deals", - integrations: { crm: integration("dealcloud").describe("CRM account") }, - input: z.object({ updatedSince: z.string().optional() }), - output: z.object({ synced: z.number() }), - async handler(input, { crm }) { - return { synced: 1 }; - }, - }); - `); - const collected = yield* makeInProcessAppToolExecutor().collect(bundled.code, { - fileSlug: "sync-deals", - sourcePath: "tools/sync-deals.ts", - }); - expect(collected.tools[0]?.integrations).toEqual({ - crm: { slug: "dealcloud", mode: "one", description: "CRM account" }, - }); - expect(collected.tools[0]?.inputSchema).toMatchObject({ - properties: { crm: { type: "string", description: "CRM account" } }, - required: ["crm"], - }); - }), - ); - - it.effect("rejects integration keys that collide with input fields", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool, integration } from "executor:app"; - export default defineTool({ - description: "Bad collision", - integrations: { crm: integration("dealcloud") }, - input: z.object({ crm: z.string() }), - async handler(input) { - return input; - }, - }); - `); - const error = yield* makeInProcessAppToolExecutor() - .collect(bundled.code, { - fileSlug: "sync-deals", - sourcePath: "tools/sync-deals.ts", - }) - .pipe(Effect.flip); - expect(error).toMatchObject({ message: expect.stringContaining("collides") }); - }), - ); - - it.effect("collects record and factory exports with file-prefixed names", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool } from "executor:app"; - export default async () => ({ - first: defineTool({ - description: "First", - input: z.object({ value: z.string() }), - handler(input) { return input; }, - }), - second: defineTool({ - description: "Second", - input: z.object({ value: z.string() }), - handler(input) { return input; }, - }), - }); - `); - const collected = yield* makeInProcessAppToolExecutor().collect(bundled.code, { - fileSlug: "sync-deals", - sourcePath: "tools/sync-deals.ts", - }); - expect(collected.tools.map((tool) => tool.toolName)).toEqual([ - "sync-deals__first", - "sync-deals__second", - ]); - }), - ); - - it.effect("rejects record keys that collide with the single export slug", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool } from "executor:app"; - export default { - "sync-deals": defineTool({ - description: "Collides", - input: z.object({ value: z.string() }), - handler(input) { return input; }, - }), - }; - `); - const error = yield* makeInProcessAppToolExecutor() - .collect(bundled.code, { - fileSlug: "sync-deals", - sourcePath: "tools/sync-deals.ts", - }) - .pipe(Effect.flip); - expect(error).toMatchObject({ message: expect.stringContaining("collides") }); - }), - ); - - it.effect("routes integration method calls through the bridge", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool, integration } from "executor:app"; - export default defineTool({ - description: "Refresh deals", - integrations: { crm: integration("dealcloud") }, - input: z.object({ updatedSince: z.string().optional() }), - output: z.object({ synced: z.number() }), - async handler({ updatedSince }, { crm }) { - const deals = await crm.deals.list({ limit: 2, updatedSince }); - return { synced: deals.length }; - }, - }); - `); - const calls: unknown[] = []; - const result = yield* makeInProcessAppToolExecutor().invoke( - bundled.code, - { toolName: "sync-deals" }, - { crm: "tools.dealcloud.org.main" }, - { - call: async (toolPath, args) => { - calls.push({ toolPath, args }); - return [{ id: 1 }, { id: 2 }]; - }, - }, - { timeoutMs: 1000 }, - ); - expect(result.output).toEqual({ synced: 2 }); - expect(calls).toEqual([ - { toolPath: "crm.deals.list", args: { limit: 2, updatedSince: undefined } }, - ]); - }), - ); - - it.effect("passes fan-out integrations as arrays of proxies", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool, integration } from "executor:app"; - export default defineTool({ - description: "Fan out", - integrations: { inboxes: integration("gmail").array() }, - input: z.object({ q: z.string() }), - output: z.object({ seen: z.number() }), - async handler({ q }, { inboxes }) { - const batches = await Promise.all(inboxes.map((inbox) => inbox.messages.list({ q }))); - return { seen: batches.flat().length }; - }, - }); - `); - const calls: unknown[] = []; - const result = yield* makeInProcessAppToolExecutor().invoke( - bundled.code, - { toolName: "sync-deals" }, - { - q: "unread", - inboxes: ["tools.gmail.org.work", "tools.gmail.user.personal"], - }, - { - call: async (toolPath, args) => { - calls.push({ toolPath, args }); - return toolPath.startsWith("inboxes#0.") ? [{ id: 1 }] : [{ id: 2 }, { id: 3 }]; - }, - }, - { timeoutMs: 1000 }, - ); - expect(result.output).toEqual({ seen: 3 }); - expect(calls).toEqual([ - { toolPath: "inboxes#0.messages.list", args: { q: "unread" } }, - { toolPath: "inboxes#1.messages.list", args: { q: "unread" } }, - ]); - }), - ); - - it.effect("classifies input validation failure", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool } from "executor:app"; - export default defineTool({ - description: "Validate", - input: z.object({ value: z.string() }), - handler(input) { return input; }, - }); - `); - const error = yield* makeInProcessAppToolExecutor() - .invoke( - bundled.code, - { toolName: "sync-deals" }, - { value: 1 }, - { call: async () => null }, - { - timeoutMs: 1000, - }, - ) - .pipe(Effect.flip); - expect(error).toMatchObject({ message: expect.stringContaining("validation failed") }); - }), - ); - - it.effect("enforces timeout", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool } from "executor:app"; - export default defineTool({ - description: "Slow", - input: z.object({}), - async handler() { - await new Promise((resolve) => setTimeout(resolve, 50)); - return {}; - }, - }); - `); - const error = yield* makeInProcessAppToolExecutor() - .invoke( - bundled.code, - { toolName: "sync-deals" }, - {}, - { call: async () => null }, - { - timeoutMs: 1, - }, - ) - .pipe(Effect.flip); - expect(error).toMatchObject({ message: expect.stringContaining("timed out") }); - }), - ); -}); diff --git a/packages/plugins/apps/src/executor/app-tool-executor.ts b/packages/plugins/apps/src/executor/app-tool-executor.ts deleted file mode 100644 index 90be90bf4e..0000000000 --- a/packages/plugins/apps/src/executor/app-tool-executor.ts +++ /dev/null @@ -1,503 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error -- boundary: in-process dynamic-import backing converts JavaScript exceptions into typed AppExecutorError */ -import { Buffer } from "node:buffer"; -import { Data, Effect, Predicate } from "effect"; - -import { validToolKey } from "../pipeline/discover"; -import { stableStringify } from "../pipeline/descriptor"; - -export class AppExecutorError extends Data.TaggedError("AppExecutorError")<{ - readonly message: string; - readonly kind: - | "bundle" - | "collect" - | "invoke" - | "timeout" - | "nondeterministic" - | "input_validation" - | "output_validation"; - readonly diagnostics?: readonly { readonly path: string; readonly message: string }[]; - readonly cause?: unknown; -}> {} - -export interface CollectedTool { - readonly toolName: string; - readonly exportKey?: string; - readonly description: string; - readonly inputSchema?: unknown; - readonly outputSchema?: unknown; - readonly integrations: Readonly< - Record< - string, - { - readonly slug: string; - readonly mode: "one" | "many"; - readonly description?: string; - } - > - >; - readonly annotations?: { - readonly readOnly?: boolean; - readonly destructive?: boolean; - readonly requiresApproval?: boolean; - }; -} - -export interface CollectedModule { - readonly tools: readonly CollectedTool[]; -} - -export interface AppToolBridge { - readonly call: (toolPath: string, args: unknown) => Promise; -} - -export interface InvokeOutcome { - readonly output: unknown; -} - -export interface AppToolInvokeLimits { - readonly timeoutMs: number; - readonly isolateKey?: string; -} - -export interface AppToolExecutor { - readonly collect: ( - bundle: string, - input: { readonly fileSlug: string; readonly sourcePath: string }, - ) => Effect.Effect; - readonly invoke: ( - bundle: string, - entry: { readonly toolName: string }, - input: unknown, - bridge: AppToolBridge, - limits: AppToolInvokeLimits, - ) => Effect.Effect; -} - -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === "object" && !Array.isArray(value); - -const moduleUrl = (bundle: string): string => - `data:text/javascript;base64,${Buffer.from(bundle, "utf8").toString("base64")}#${crypto.randomUUID()}`; - -const importBundle = (bundle: string): Promise => import(moduleUrl(bundle)); - -const resolveDefault = async (bundle: string): Promise => { - const mod = (await importBundle(bundle)) as { readonly default?: unknown }; - const value = mod.default; - return typeof value === "function" ? await (value as () => unknown | Promise)() : value; -}; - -const toIssue = ( - issue: unknown, -): { readonly message: string; readonly path?: readonly unknown[] } => { - if (isRecord(issue) && typeof issue.message === "string") { - return { - message: issue.message, - ...(Array.isArray(issue.path) ? { path: issue.path } : {}), - }; - } - return { message: String(issue) }; -}; - -const validateStandard = async ( - schema: unknown, - value: unknown, - field: "input" | "output", -): Promise => { - if (!isRecord(schema) || !isRecord(schema["~standard"])) return value; - const validate = schema["~standard"].validate; - if (typeof validate !== "function") return value; - const result = await validate(value); - if (isRecord(result) && "issues" in result && Array.isArray(result.issues)) { - throw new AppExecutorError({ - kind: field === "input" ? "input_validation" : "output_validation", - message: `${field} validation failed`, - cause: result.issues.map(toIssue), - }); - } - return isRecord(result) && "value" in result ? result.value : value; -}; - -const jsonSchemaFor = ( - schema: unknown, - side: "input" | "output", - toolName: string, - sourcePath: string, -): unknown => { - if (schema === undefined) return undefined; - if (isRecord(schema) && isRecord(schema["~standard"])) { - const jsonSchema = schema["~standard"].jsonSchema; - if (!isRecord(jsonSchema) || typeof jsonSchema[side] !== "function") { - throw new AppExecutorError({ - kind: "collect", - message: `tool "${toolName}" ${side} schema library does not expose the Standard Schema jsonSchema extension`, - diagnostics: [ - { - path: sourcePath, - message: `${side} schema library does not expose the Standard Schema jsonSchema extension`, - }, - ], - }); - } - try { - const converted = jsonSchema[side]({ target: "draft-2020-12" }); - if (!isRecord(converted)) return converted; - const { $schema: _metaSchema, ...withoutMetaSchema } = converted; - return withoutMetaSchema; - } catch (cause) { - throw new AppExecutorError({ - kind: "collect", - message: `tool "${toolName}" ${side} schema conversion failed`, - diagnostics: [{ path: sourcePath, message: `${side} schema conversion failed` }], - cause, - }); - } - } - return schema; -}; - -const innerToolFailureMessage = (cause: unknown): string | null => { - if (!Predicate.isTagged("AppInnerToolError")(cause)) return null; - const error = cause as { readonly address?: unknown; readonly innerMessage?: unknown }; - if (typeof error.address !== "string" || typeof error.innerMessage !== "string") return null; - return `Inner tool ${error.address} failed: "${error.innerMessage}"`; -}; - -type CollectedIntegrationDecl = CollectedTool["integrations"][string]; - -const integrationDeclaration = (value: unknown): CollectedIntegrationDecl | null => { - if (!isRecord(value)) return null; - if (value.kind !== "integration") return null; - if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) { - return null; - } - return { - slug: value.slug, - mode: value.mode, - ...(typeof value.description === "string" ? { description: value.description } : {}), - }; -}; - -const collectIntegrations = ( - toolName: string, - sourcePath: string, - declarations: unknown, - inputJsonSchema: unknown, -): CollectedTool["integrations"] => { - if (declarations === undefined) return {}; - if (!isRecord(declarations)) { - throw new AppExecutorError({ - kind: "collect", - message: `tool "${toolName}" integrations must be a record`, - diagnostics: [{ path: sourcePath, message: "integrations must be a record" }], - }); - } - const inputProperties = - isRecord(inputJsonSchema) && isRecord(inputJsonSchema.properties) - ? inputJsonSchema.properties - : {}; - const out: Record = {}; - for (const [field, raw] of Object.entries(declarations)) { - if (Object.prototype.hasOwnProperty.call(inputProperties, field)) { - throw new AppExecutorError({ - kind: "collect", - message: `tool "${toolName}" integration key "${field}" collides with input field`, - diagnostics: [ - { path: sourcePath, message: `integration key collides with input: ${field}` }, - ], - }); - } - const decl = integrationDeclaration(raw); - if (!decl) { - throw new AppExecutorError({ - kind: "collect", - message: `tool "${toolName}" integration "${field}" is not a valid declaration`, - diagnostics: [{ path: sourcePath, message: `invalid integration declaration: ${field}` }], - }); - } - out[field] = decl; - } - return out; -}; - -const projectInputSchema = ( - inputJsonSchema: unknown, - integrations: CollectedTool["integrations"], -): unknown => { - const base = isRecord(inputJsonSchema) ? inputJsonSchema : {}; - const properties = isRecord(base.properties) ? { ...base.properties } : {}; - const required = Array.isArray(base.required) ? [...base.required] : []; - for (const [field, decl] of Object.entries(integrations)) { - if (decl.mode === "one") { - properties[field] = { - type: "string", - description: decl.description ?? `Connection for ${decl.slug}`, - }; - required.push(field); - continue; - } - properties[field] = { - type: "array", - items: { type: "string" }, - description: decl.description ?? `Connections for ${decl.slug}`, - }; - required.push(field); - } - return { - ...base, - type: "object", - properties, - ...(required.length > 0 ? { required: [...new Set(required)] } : {}), - }; -}; - -const isDefinedTool = ( - value: unknown, -): value is { - readonly description: string; - readonly integrations?: unknown; - readonly input: unknown; - readonly output?: unknown; - readonly annotations?: CollectedTool["annotations"]; - readonly handler: (input: unknown, ctx: Record) => unknown; -} => - isRecord(value) && - value["~executorAppTool"] === true && - typeof value.description === "string" && - "input" in value && - typeof value.handler === "function"; - -const collectFromExport = ( - exported: unknown, - fileSlug: string, - sourcePath: string, -): CollectedModule => { - if (isDefinedTool(exported)) { - const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath); - const integrations = collectIntegrations( - fileSlug, - sourcePath, - exported.integrations, - inputJsonSchema, - ); - return { - tools: [ - { - toolName: fileSlug, - description: exported.description, - integrations, - inputSchema: projectInputSchema(inputJsonSchema, integrations), - outputSchema: jsonSchemaFor(exported.output, "output", fileSlug, sourcePath), - annotations: exported.annotations, - }, - ], - }; - } - if (!isRecord(exported)) { - throw new AppExecutorError({ - kind: "collect", - message: `default export in ${sourcePath} must be a tool, record, or factory`, - diagnostics: [{ path: sourcePath, message: "unsupported default export" }], - }); - } - const tools: CollectedTool[] = []; - const seen = new Set(); - if (isDefinedTool(exported[fileSlug])) { - throw new AppExecutorError({ - kind: "collect", - message: `record export key "${fileSlug}" collides with single tool name`, - diagnostics: [{ path: sourcePath, message: `record key "${fileSlug}" is reserved` }], - }); - } - for (const [key, value] of Object.entries(exported)) { - if (!isDefinedTool(value)) continue; - if (!validToolKey(key)) { - throw new AppExecutorError({ - kind: "collect", - message: `record export key "${key}" is not a valid tool slug`, - diagnostics: [{ path: sourcePath, message: `invalid record key "${key}"` }], - }); - } - const toolName = `${fileSlug}__${key}`; - if (seen.has(toolName)) { - throw new AppExecutorError({ - kind: "collect", - message: `duplicate tool name "${toolName}"`, - diagnostics: [{ path: sourcePath, message: `duplicate tool name "${toolName}"` }], - }); - } - const inputJsonSchema = jsonSchemaFor(value.input, "input", toolName, sourcePath); - const integrations = collectIntegrations( - toolName, - sourcePath, - value.integrations, - inputJsonSchema, - ); - seen.add(toolName); - tools.push({ - toolName, - exportKey: key, - description: value.description, - integrations, - inputSchema: projectInputSchema(inputJsonSchema, integrations), - outputSchema: jsonSchemaFor(value.output, "output", toolName, sourcePath), - annotations: value.annotations, - }); - } - if (tools.length === 0) { - throw new AppExecutorError({ - kind: "collect", - message: `record export in ${sourcePath} contains no defineTool entries`, - diagnostics: [{ path: sourcePath, message: "no tools found" }], - }); - } - return { tools }; -}; - -const selectTool = (exported: unknown, entry: string): unknown => { - if (isDefinedTool(exported)) return exported; - if (!isRecord(exported)) return null; - const marker = "__"; - const index = entry.indexOf(marker); - if (index === -1) return null; - return exported[entry.slice(index + marker.length)]; -}; - -const timeout = (promise: Promise, timeoutMs: number): Promise => - Promise.race([ - promise, - new Promise((_, reject) => { - setTimeout( - () => - reject( - new AppExecutorError({ - kind: "timeout", - message: `app tool timed out after ${timeoutMs}ms`, - }), - ), - timeoutMs, - ); - }), - ]); - -const makeClient = (root: string, prefix: readonly string[], bridge: AppToolBridge): unknown => - new Proxy(() => undefined, { - get(_target, prop) { - if (prop === "then" || typeof prop === "symbol") return undefined; - return makeClient(root, [...prefix, String(prop)], bridge); - }, - apply(_target, _thisArg, args) { - return bridge.call(`${root}.${prefix.join(".")}`, args[0] ?? {}); - }, - }); - -const splitInvokeInput = ( - toolName: string, - input: unknown, - integrations: CollectedTool["integrations"], - bridge: AppToolBridge, -): { readonly input: Record; readonly integrations: Record } => { - const payload = isRecord(input) ? input : {}; - const dataInput: Record = { ...payload }; - const handles: Record = {}; - for (const [field, decl] of Object.entries(integrations)) { - const raw = payload[field]; - delete dataInput[field]; - if (decl.mode === "one") { - if (typeof raw !== "string" || raw.length === 0) { - throw new AppExecutorError({ - kind: "input_validation", - message: `tool "${toolName}" integration "${field}" must be a connection address`, - }); - } - handles[field] = makeClient(field, [], bridge); - continue; - } - if (!Array.isArray(raw) || raw.some((item) => typeof item !== "string" || item.length === 0)) { - throw new AppExecutorError({ - kind: "input_validation", - message: `tool "${toolName}" integration "${field}" must be connection addresses`, - }); - } - handles[field] = raw.map((_address, index) => makeClient(`${field}#${index}`, [], bridge)); - } - return { input: dataInput, integrations: handles }; -}; - -/** In-process backing for unit tests only. It is not a security boundary. */ -export const makeInProcessAppToolExecutor = (): AppToolExecutor => ({ - collect: (bundle, input) => - Effect.tryPromise({ - try: async () => { - const first = collectFromExport( - await resolveDefault(bundle), - input.fileSlug, - input.sourcePath, - ); - const second = collectFromExport( - await resolveDefault(bundle), - input.fileSlug, - input.sourcePath, - ); - if (stableStringify(first) !== stableStringify(second)) { - throw new AppExecutorError({ - kind: "nondeterministic", - message: `collect for ${input.sourcePath} is nondeterministic`, - diagnostics: [ - { path: input.sourcePath, message: "factory output changed between runs" }, - ], - }); - } - return first; - }, - catch: (cause) => - cause instanceof AppExecutorError - ? cause - : new AppExecutorError({ - kind: "collect", - message: `collect failed for ${input.sourcePath}`, - cause, - }), - }), - invoke: (bundle, entry, input, bridge, limits) => - Effect.tryPromise({ - try: async () => - timeout( - (async () => { - const exported = await resolveDefault(bundle); - const tool = selectTool(exported, entry.toolName); - if (!isDefinedTool(tool)) { - throw new AppExecutorError({ - kind: "invoke", - message: `tool not found in bundle: ${entry.toolName}`, - }); - } - const inputJsonSchema = jsonSchemaFor( - tool.input, - "input", - entry.toolName, - entry.toolName, - ); - const integrations = collectIntegrations( - entry.toolName, - entry.toolName, - tool.integrations, - inputJsonSchema, - ); - const split = splitInvokeInput(entry.toolName, input, integrations, bridge); - const decoded = await validateStandard(tool.input, split.input, "input"); - const output = await tool.handler(decoded, split.integrations); - return { output: await validateStandard(tool.output, output, "output") }; - })(), - limits.timeoutMs, - ), - catch: (cause) => - cause instanceof AppExecutorError - ? cause - : new AppExecutorError({ - kind: "invoke", - message: innerToolFailureMessage(cause) ?? "app tool invocation failed", - cause, - }), - }), -}); diff --git a/packages/plugins/apps/src/executor/dynamic-worker-app-tool-executor.ts b/packages/plugins/apps/src/executor/dynamic-worker-app-tool-executor.ts deleted file mode 100644 index 50d3772669..0000000000 --- a/packages/plugins/apps/src/executor/dynamic-worker-app-tool-executor.ts +++ /dev/null @@ -1,345 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-instanceof-tagged-error, executor/no-error-constructor -- boundary: Worker Loader RPC adapter converts worker envelopes into typed AppExecutorError */ -import { Effect } from "effect"; - -import { stableStringify } from "../pipeline/descriptor"; -import { - AppExecutorError, - type AppToolBridge, - type AppToolExecutor, - type CollectedModule, - type InvokeOutcome, -} from "./app-tool-executor"; - -type DynamicWorkerDefinition = { - readonly compatibilityDate: string; - readonly compatibilityFlags?: string[]; - readonly mainModule: string; - readonly modules: Readonly>; - readonly globalOutbound?: null; -}; - -type DynamicWorkerLoader = { - readonly load?: (code: DynamicWorkerDefinition) => { readonly getEntrypoint: () => unknown }; - readonly get?: ( - name: string | null, - factory: () => DynamicWorkerDefinition, - ) => { readonly getEntrypoint: () => unknown }; -}; - -type DynamicAppEntrypoint = { - readonly collect: (input: { - readonly fileSlug: string; - readonly sourcePath: string; - }) => Promise>; - readonly invoke: ( - input: { - readonly toolName: string; - readonly input: unknown; - readonly timeoutMs: number; - }, - bridge: AppToolBridge, - ) => Promise>; -}; - -type WorkerSuccess = { - readonly ok: true; - readonly value: T; -}; - -type WorkerFailure = { - readonly ok: false; - readonly kind: AppExecutorError["kind"]; - readonly message: string; - readonly diagnostics?: readonly { readonly path: string; readonly message: string }[]; - readonly cause?: unknown; -}; - -type WorkerEnvelope = WorkerSuccess | WorkerFailure; - -export interface DynamicWorkerAppToolExecutorOptions { - readonly loader: DynamicWorkerLoader; -} - -const toAppExecutorError = (failure: WorkerFailure, fallbackKind: AppExecutorError["kind"]) => - new AppExecutorError({ - kind: failure.kind ?? fallbackKind, - message: failure.message, - ...(failure.diagnostics ? { diagnostics: failure.diagnostics } : {}), - ...(failure.cause === undefined ? {} : { cause: failure.cause }), - }); - -const mapCause = (cause: unknown, kind: AppExecutorError["kind"], message: string) => - cause instanceof AppExecutorError - ? cause - : new AppExecutorError({ - kind, - message, - cause, - }); - -// Bump this when appWorkerModule() changes so stable Worker Loader IDs do not -// reuse an isolate running an older driver around byte-identical app bundles. -export const DRIVER_VERSION = "1"; - -const appWorkerModule = (): string => ` -import { WorkerEntrypoint } from "cloudflare:workers"; -import artifact from "./app.js"; - -const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value); -const validToolKey = (value) => /^[a-z][a-z0-9-]*(?:_[a-z0-9-]+)*$/.test(value); -const stableStringify = (value) => { - const seen = new WeakSet(); - const walk = (v) => { - if (v === null || typeof v !== "object") return v; - if (seen.has(v)) return "[Circular]"; - seen.add(v); - if (Array.isArray(v)) return v.map(walk); - const out = {}; - for (const key of Object.keys(v).sort()) { - const inner = v[key]; - if (inner !== undefined) out[key] = walk(inner); - } - return out; - }; - return JSON.stringify(walk(value)); -}; -const issue = (path, message) => ({ path, message }); -const fail = (kind, message, diagnostics, cause) => ({ __appExecutorFailure: true, kind, message, diagnostics, cause }); -const isDefinedTool = (value) => - isRecord(value) && - value["~executorAppTool"] === true && - typeof value.description === "string" && - "input" in value && - typeof value.handler === "function"; -const resolveDefault = async () => typeof artifact === "function" ? await artifact() : artifact; -const integrationDeclaration = (value) => { - if (!isRecord(value) || value.kind !== "integration") return null; - if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null; - return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) }; -}; -const jsonSchemaFor = (schema, side, toolName, sourcePath) => { - if (schema === undefined) return undefined; - if (!isRecord(schema) || !isRecord(schema["~standard"])) return schema; - const jsonSchema = schema["~standard"].jsonSchema; - if (!isRecord(jsonSchema) || typeof jsonSchema[side] !== "function") { - throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema library does not expose the Standard Schema jsonSchema extension', [issue(sourcePath, side + " schema library does not expose the Standard Schema jsonSchema extension")]); - } - try { - const converted = jsonSchema[side]({ target: "draft-2020-12" }); - if (!isRecord(converted)) return converted; - const { $schema: _metaSchema, ...withoutMetaSchema } = converted; - return withoutMetaSchema; - } catch (cause) { - throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema conversion failed', [issue(sourcePath, side + " schema conversion failed")], cause); - } -}; -const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => { - if (declarations === undefined) return {}; - if (!isRecord(declarations)) throw fail("collect", 'tool "' + toolName + '" integrations must be a record', [issue(sourcePath, "integrations must be a record")]); - const inputProperties = isRecord(inputJsonSchema) && isRecord(inputJsonSchema.properties) ? inputJsonSchema.properties : {}; - const out = {}; - for (const [field, raw] of Object.entries(declarations)) { - if (Object.prototype.hasOwnProperty.call(inputProperties, field)) throw fail("collect", 'tool "' + toolName + '" integration key "' + field + '" collides with input field', [issue(sourcePath, "integration key collides with input: " + field)]); - const decl = integrationDeclaration(raw); - if (!decl) throw fail("collect", 'tool "' + toolName + '" integration "' + field + '" is not a valid declaration', [issue(sourcePath, "invalid integration declaration: " + field)]); - out[field] = decl; - } - return out; -}; -const projectInputSchema = (inputJsonSchema, integrations) => { - const base = isRecord(inputJsonSchema) ? inputJsonSchema : {}; - const properties = isRecord(base.properties) ? { ...base.properties } : {}; - const required = Array.isArray(base.required) ? [...base.required] : []; - for (const [field, decl] of Object.entries(integrations)) { - properties[field] = decl.mode === "one" ? { type: "string", description: decl.description ?? "Connection for " + decl.slug } : { type: "array", items: { type: "string" }, description: decl.description ?? "Connections for " + decl.slug }; - required.push(field); - } - return { ...base, type: "object", properties, ...(required.length > 0 ? { required: [...new Set(required)] } : {}) }; -}; -const collectFromExport = (exported, fileSlug, sourcePath) => { - if (isDefinedTool(exported)) { - const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath); - const integrations = collectIntegrations(fileSlug, sourcePath, exported.integrations, inputJsonSchema); - return { tools: [{ toolName: fileSlug, description: exported.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(exported.output, "output", fileSlug, sourcePath), annotations: exported.annotations }] }; - } - if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]); - const tools = []; - const seen = new Set(); - if (isDefinedTool(exported[fileSlug])) throw fail("collect", 'record export key "' + fileSlug + '" collides with single tool name', [issue(sourcePath, 'record key "' + fileSlug + '" is reserved')]); - for (const [key, value] of Object.entries(exported)) { - if (!isDefinedTool(value)) continue; - if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]); - const toolName = fileSlug + "__" + key; - if (seen.has(toolName)) throw fail("collect", 'duplicate tool name "' + toolName + '"', [issue(sourcePath, "duplicate tool name " + toolName)]); - const inputJsonSchema = jsonSchemaFor(value.input, "input", toolName, sourcePath); - const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema); - seen.add(toolName); - tools.push({ toolName, exportKey: key, description: value.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(value.output, "output", toolName, sourcePath), annotations: value.annotations }); - } - if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]); - return { tools }; -}; -const selectTool = (exported, entry) => { - if (isDefinedTool(exported)) return exported; - if (!isRecord(exported)) return null; - const index = entry.indexOf("__"); - return index === -1 ? null : exported[entry.slice(index + 2)]; -}; -const validateStandard = async (schema, value, field) => { - if (!isRecord(schema) || !isRecord(schema["~standard"]) || typeof schema["~standard"].validate !== "function") return value; - const result = await schema["~standard"].validate(value); - if (isRecord(result) && "issues" in result && Array.isArray(result.issues)) throw fail(field === "input" ? "input_validation" : "output_validation", field + " validation failed", undefined, result.issues); - return isRecord(result) && "value" in result ? result.value : value; -}; -const makeClient = (bridge, root, prefix = []) => new Proxy(() => undefined, { - get(_target, prop) { - if (prop === "then" || typeof prop === "symbol") return undefined; - return makeClient(bridge, root, [...prefix, String(prop)]); - }, - async apply(_target, _thisArg, args) { - return await bridge.call(root + "." + prefix.join("."), args[0] ?? {}); - }, -}); -const splitInvokeInput = (bridge, toolName, input, integrations) => { - const payload = isRecord(input) ? input : {}; - const dataInput = { ...payload }; - const handles = {}; - for (const [field, decl] of Object.entries(integrations)) { - const raw = payload[field]; - delete dataInput[field]; - if (decl.mode === "one") { - if (typeof raw !== "string" || raw.length === 0) throw fail("input_validation", 'tool "' + toolName + '" integration "' + field + '" must be a connection address'); - handles[field] = makeClient(bridge, field); - } else { - if (!Array.isArray(raw) || raw.some((item) => typeof item !== "string" || item.length === 0)) throw fail("input_validation", 'tool "' + toolName + '" integration "' + field + '" must be connection addresses'); - handles[field] = raw.map((_address, index) => makeClient(bridge, field + "#" + index)); - } - } - return { input: dataInput, integrations: handles }; -}; -const serializeFailure = (error, fallbackKind) => { - if (isRecord(error) && error.__appExecutorFailure === true) return { ok: false, kind: error.kind, message: error.message, diagnostics: error.diagnostics, cause: error.cause }; - return { ok: false, kind: fallbackKind, message: error && error.message ? error.message : String(error) }; -}; - -export default class AppExecutor extends WorkerEntrypoint { - async collect(input) { - try { - const first = collectFromExport(await resolveDefault(), input.fileSlug, input.sourcePath); - const second = collectFromExport(await resolveDefault(), input.fileSlug, input.sourcePath); - if (stableStringify(first) !== stableStringify(second)) throw fail("nondeterministic", "collect for " + input.sourcePath + " is nondeterministic", [issue(input.sourcePath, "factory output changed between runs")]); - return { ok: true, value: first }; - } catch (error) { - return serializeFailure(error, "collect"); - } - } - - async invoke(input, bridge) { - try { - const invoke = async () => { - const exported = await resolveDefault(); - const tool = selectTool(exported, input.toolName); - if (!isDefinedTool(tool)) throw fail("invoke", "tool not found in bundle: " + input.toolName); - const inputJsonSchema = jsonSchemaFor(tool.input, "input", input.toolName, input.toolName); - const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema); - const split = splitInvokeInput(bridge, input.toolName, input.input, integrations); - const decoded = await validateStandard(tool.input, split.input, "input"); - const output = await tool.handler(decoded, split.integrations); - return { output: await validateStandard(tool.output, output, "output") }; - }; - const value = await Promise.race([ - invoke(), - new Promise((_, reject) => setTimeout(() => reject(fail("timeout", "app tool timed out after " + input.timeoutMs + "ms")), input.timeoutMs)), - ]); - return { ok: true, value }; - } catch (error) { - return serializeFailure(error, "invoke"); - } - } -} -`; - -const asEntrypoint = (value: unknown): DynamicAppEntrypoint => value as DynamicAppEntrypoint; - -type StartedDynamicAppWorker = { - readonly stub: { readonly getEntrypoint: () => unknown }; - readonly entrypoint: DynamicAppEntrypoint; -}; - -const workerDefinition = (bundle: string): DynamicWorkerDefinition => ({ - compatibilityDate: "2025-06-01", - compatibilityFlags: ["nodejs_compat"], - mainModule: "driver.js", - modules: { - "driver.js": appWorkerModule(), - "app.js": bundle, - }, - globalOutbound: null, -}); - -const startOneShotWorker = ( - loader: DynamicWorkerLoader, - bundle: string, -): StartedDynamicAppWorker => { - const definition = workerDefinition(bundle); - const worker = - loader.load !== undefined - ? loader.load(definition) - : loader.get?.(`app-tool-${crypto.randomUUID()}`, () => definition); - if (worker === undefined) { - throw new AppExecutorError({ - kind: "collect", - message: "Worker Loader binding does not support load() or get()", - }); - } - return { stub: worker, entrypoint: asEntrypoint(worker.getEntrypoint()) }; -}; - -const startStableWorker = ( - loader: DynamicWorkerLoader, - bundle: string, - isolateKey: string, -): StartedDynamicAppWorker => { - const worker = loader.get?.(isolateKey, () => workerDefinition(bundle)); - if (worker === undefined) { - throw new AppExecutorError({ - kind: "invoke", - message: "Worker Loader binding does not support get()", - }); - } - return { stub: worker, entrypoint: asEntrypoint(worker.getEntrypoint()) }; -}; - -export const makeDynamicWorkerAppToolExecutor = ( - options: DynamicWorkerAppToolExecutorOptions, -): AppToolExecutor => ({ - collect: (bundle, input) => - Effect.tryPromise({ - try: async () => { - const worker = startOneShotWorker(options.loader, bundle); - const response = await worker.entrypoint.collect(input); - if (!response.ok) throw toAppExecutorError(response, "collect"); - if (!stableStringify(response.value)) - throw new Error(`collect failed for ${input.sourcePath}`); - return response.value; - }, - catch: (cause) => mapCause(cause, "collect", `collect failed for ${input.sourcePath}`), - }), - invoke: (bundle, entry, input, bridge, limits) => - Effect.tryPromise({ - try: async () => { - const worker = startStableWorker( - options.loader, - bundle, - limits.isolateKey ?? `app-tool-${crypto.randomUUID()}`, - ); - const response = await worker.entrypoint.invoke( - { toolName: entry.toolName, input, timeoutMs: limits.timeoutMs }, - bridge, - ); - if (!response.ok) throw toAppExecutorError(response, "invoke"); - return response.value; - }, - catch: (cause) => mapCause(cause, "invoke", "app tool invocation failed"), - }), -}); diff --git a/packages/plugins/apps/src/executor/workerd-app-tool-executor.ts b/packages/plugins/apps/src/executor/workerd-app-tool-executor.ts deleted file mode 100644 index 6189aaa2e6..0000000000 --- a/packages/plugins/apps/src/executor/workerd-app-tool-executor.ts +++ /dev/null @@ -1,326 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error, executor/no-instanceof-error, executor/no-unknown-error-message -- workerd boundary converts worker envelopes and subprocess startup failures into typed AppExecutorError */ -import { Effect } from "effect"; - -import { - createWorkerdModuleRunner, - isWorkerdAvailable, - WORKERD_VERSION, - type WorkerdModuleRunnerOptions, -} from "@executor-js/runtime-workerd-subprocess"; - -import { stableStringify } from "../pipeline/descriptor"; -import { - AppExecutorError, - type AppToolBridge, - type AppToolExecutor, - type CollectedModule, - type InvokeOutcome, -} from "./app-tool-executor"; - -export { isWorkerdAvailable, WORKERD_VERSION as WORKERD_APP_EXECUTOR_VERSION }; - -type WorkerSuccess = { - readonly ok: true; - readonly value: T; -}; - -type WorkerFailure = { - readonly ok: false; - readonly kind: AppExecutorError["kind"]; - readonly message: string; - readonly diagnostics?: readonly { readonly path: string; readonly message: string }[]; - readonly cause?: unknown; -}; - -type WorkerEnvelope = WorkerSuccess | WorkerFailure; - -const toAppExecutorError = (failure: WorkerFailure, fallbackKind: AppExecutorError["kind"]) => - new AppExecutorError({ - kind: failure.kind ?? fallbackKind, - message: failure.message, - ...(failure.diagnostics ? { diagnostics: failure.diagnostics } : {}), - ...(failure.cause === undefined ? {} : { cause: failure.cause }), - }); - -const mapCause = (cause: unknown, kind: AppExecutorError["kind"], message: string) => - cause instanceof AppExecutorError - ? cause - : new AppExecutorError({ - kind, - message: `${message}: ${cause instanceof Error ? cause.message : String(cause)}`, - cause, - }); - -const appDriver = (token: string): string => ` -import artifact from "./app.js"; - -const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value); -const validToolKey = (value) => /^[a-z][a-z0-9-]*(?:_[a-z0-9-]+)*$/.test(value); -const stableStringify = (value) => { - const seen = new WeakSet(); - const walk = (v) => { - if (v === null || typeof v !== "object") return v; - if (seen.has(v)) return "[Circular]"; - seen.add(v); - if (Array.isArray(v)) return v.map(walk); - const out = {}; - for (const key of Object.keys(v).sort()) { - const inner = v[key]; - if (inner !== undefined) out[key] = walk(inner); - } - return out; - }; - return JSON.stringify(walk(value)); -}; -const issue = (path, message) => ({ path, message }); -const fail = (kind, message, diagnostics, cause) => ({ __appExecutorFailure: true, kind, message, diagnostics, cause }); -const isDefinedTool = (value) => - isRecord(value) && - value["~executorAppTool"] === true && - typeof value.description === "string" && - "input" in value && - typeof value.handler === "function"; -const resolveDefault = async () => typeof artifact === "function" ? await artifact() : artifact; -const integrationDeclaration = (value) => { - if (!isRecord(value) || value.kind !== "integration") return null; - if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null; - return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) }; -}; -const jsonSchemaFor = (schema, side, toolName, sourcePath) => { - if (schema === undefined) return undefined; - if (!isRecord(schema) || !isRecord(schema["~standard"])) return schema; - const jsonSchema = schema["~standard"].jsonSchema; - if (!isRecord(jsonSchema) || typeof jsonSchema[side] !== "function") { - throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema library does not expose the Standard Schema jsonSchema extension', [issue(sourcePath, side + " schema library does not expose the Standard Schema jsonSchema extension")]); - } - try { - const converted = jsonSchema[side]({ target: "draft-2020-12" }); - if (!isRecord(converted)) return converted; - const { $schema: _metaSchema, ...withoutMetaSchema } = converted; - return withoutMetaSchema; - } catch (cause) { - throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema conversion failed', [issue(sourcePath, side + " schema conversion failed")], cause); - } -}; -const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => { - if (declarations === undefined) return {}; - if (!isRecord(declarations)) throw fail("collect", 'tool "' + toolName + '" integrations must be a record', [issue(sourcePath, "integrations must be a record")]); - const inputProperties = isRecord(inputJsonSchema) && isRecord(inputJsonSchema.properties) ? inputJsonSchema.properties : {}; - const out = {}; - for (const [field, raw] of Object.entries(declarations)) { - if (Object.prototype.hasOwnProperty.call(inputProperties, field)) { - throw fail("collect", 'tool "' + toolName + '" integration key "' + field + '" collides with input field', [issue(sourcePath, "integration key collides with input: " + field)]); - } - const decl = integrationDeclaration(raw); - if (!decl) throw fail("collect", 'tool "' + toolName + '" integration "' + field + '" is not a valid declaration', [issue(sourcePath, "invalid integration declaration: " + field)]); - out[field] = decl; - } - return out; -}; -const projectInputSchema = (inputJsonSchema, integrations) => { - const base = isRecord(inputJsonSchema) ? inputJsonSchema : {}; - const properties = isRecord(base.properties) ? { ...base.properties } : {}; - const required = Array.isArray(base.required) ? [...base.required] : []; - for (const [field, decl] of Object.entries(integrations)) { - properties[field] = decl.mode === "one" - ? { type: "string", description: decl.description ?? "Connection for " + decl.slug } - : { type: "array", items: { type: "string" }, description: decl.description ?? "Connections for " + decl.slug }; - required.push(field); - } - return { ...base, type: "object", properties, ...(required.length > 0 ? { required: [...new Set(required)] } : {}) }; -}; -const collectFromExport = (exported, fileSlug, sourcePath) => { - if (isDefinedTool(exported)) { - const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath); - const integrations = collectIntegrations(fileSlug, sourcePath, exported.integrations, inputJsonSchema); - return { tools: [{ toolName: fileSlug, description: exported.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(exported.output, "output", fileSlug, sourcePath), annotations: exported.annotations }] }; - } - if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]); - const tools = []; - const seen = new Set(); - if (isDefinedTool(exported[fileSlug])) throw fail("collect", 'record export key "' + fileSlug + '" collides with single tool name', [issue(sourcePath, 'record key "' + fileSlug + '" is reserved')]); - for (const [key, value] of Object.entries(exported)) { - if (!isDefinedTool(value)) continue; - if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]); - const toolName = fileSlug + "__" + key; - if (seen.has(toolName)) throw fail("collect", 'duplicate tool name "' + toolName + '"', [issue(sourcePath, "duplicate tool name " + toolName)]); - const inputJsonSchema = jsonSchemaFor(value.input, "input", toolName, sourcePath); - const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema); - seen.add(toolName); - tools.push({ toolName, exportKey: key, description: value.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(value.output, "output", toolName, sourcePath), annotations: value.annotations }); - } - if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]); - return { tools }; -}; -const selectTool = (exported, entry) => { - if (isDefinedTool(exported)) return exported; - if (!isRecord(exported)) return null; - const index = entry.indexOf("__"); - return index === -1 ? null : exported[entry.slice(index + 2)]; -}; -const validateStandard = async (schema, value, field) => { - if (!isRecord(schema) || !isRecord(schema["~standard"]) || typeof schema["~standard"].validate !== "function") return value; - const result = await schema["~standard"].validate(value); - if (isRecord(result) && "issues" in result && Array.isArray(result.issues)) { - throw fail(field === "input" ? "input_validation" : "output_validation", field + " validation failed", undefined, result.issues); - } - return isRecord(result) && "value" in result ? result.value : value; -}; -const makeClient = (env, root, prefix = []) => new Proxy(() => undefined, { - get(_target, prop) { - if (prop === "then" || typeof prop === "symbol") return undefined; - return makeClient(env, root, [...prefix, String(prop)]); - }, - async apply(_target, _thisArg, args) { - const response = await env.HOST.fetch("http://host/tool", { - method: "POST", - headers: { "content-type": "application/json", "x-executor-token": ${JSON.stringify(token)} }, - body: JSON.stringify({ toolPath: root + "." + prefix.join("."), args: args[0] ?? {} }), - }); - const data = await response.json(); - if (!data.ok) throw new Error(data.error || "Internal tool error"); - return data.result; - }, -}); -const splitInvokeInput = (env, toolName, input, integrations) => { - const payload = isRecord(input) ? input : {}; - const dataInput = { ...payload }; - const handles = {}; - for (const [field, decl] of Object.entries(integrations)) { - const raw = payload[field]; - delete dataInput[field]; - if (decl.mode === "one") { - if (typeof raw !== "string" || raw.length === 0) throw fail("input_validation", 'tool "' + toolName + '" integration "' + field + '" must be a connection address'); - handles[field] = makeClient(env, field); - } else { - if (!Array.isArray(raw) || raw.some((item) => typeof item !== "string" || item.length === 0)) throw fail("input_validation", 'tool "' + toolName + '" integration "' + field + '" must be connection addresses'); - handles[field] = raw.map((_address, index) => makeClient(env, field + "#" + index)); - } - } - return { input: dataInput, integrations: handles }; -}; -const serializeFailure = (error, fallbackKind) => { - if (isRecord(error) && error.__appExecutorFailure === true) { - return { ok: false, kind: error.kind, message: error.message, diagnostics: error.diagnostics, cause: error.cause }; - } - return { ok: false, kind: fallbackKind, message: error && error.message ? error.message : String(error) }; -}; - -export default { - async fetch(request, env) { - if (request.url.endsWith("/__health")) return Response.json({ ok: true, runnerToken: ${JSON.stringify(token)} }); - if (!request.url.endsWith("/run")) return new Response("Not Found", { status: 404 }); - const input = await request.json(); - try { - if (input.op === "collect") { - const first = collectFromExport(await resolveDefault(), input.fileSlug, input.sourcePath); - const second = collectFromExport(await resolveDefault(), input.fileSlug, input.sourcePath); - if (stableStringify(first) !== stableStringify(second)) throw fail("nondeterministic", "collect for " + input.sourcePath + " is nondeterministic", [issue(input.sourcePath, "factory output changed between runs")]); - return Response.json({ ok: true, value: first }); - } - if (input.op === "invoke") { - const invoke = async () => { - const exported = await resolveDefault(); - const tool = selectTool(exported, input.toolName); - if (!isDefinedTool(tool)) throw fail("invoke", "tool not found in bundle: " + input.toolName); - const inputJsonSchema = jsonSchemaFor(tool.input, "input", input.toolName, input.toolName); - const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema); - const split = splitInvokeInput(env, input.toolName, input.input, integrations); - const decoded = await validateStandard(tool.input, split.input, "input"); - const output = await tool.handler(decoded, split.integrations); - return { output: await validateStandard(tool.output, output, "output") }; - }; - const value = await Promise.race([ - invoke(), - new Promise((_, reject) => setTimeout(() => reject(fail("timeout", "app tool timed out after " + input.timeoutMs + "ms")), input.timeoutMs)), - ]); - return Response.json({ ok: true, value }); - } - return Response.json({ ok: false, kind: "invoke", message: "unknown operation" }); - } catch (error) { - return Response.json(serializeFailure(error, input.op === "collect" ? "collect" : "invoke")); - } - }, -}; -`; - -const collectWithWorkerd = ( - bundle: string, - input: { readonly fileSlug: string; readonly sourcePath: string }, -): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const token = crypto.randomUUID(); - const runner = createWorkerdModuleRunner({ - mainModule: "driver.js", - modules: { "driver.js": appDriver(token), "app.js": bundle }, - hostToken: token, - restartBackoffMs: 1, - }); - try { - const response = await runner.run>({ - op: "collect", - fileSlug: input.fileSlug, - sourcePath: input.sourcePath, - }); - if (!response.body.ok) throw toAppExecutorError(response.body, "collect"); - return response.body.value; - } finally { - await runner.dispose(); - } - }, - catch: (cause) => mapCause(cause, "collect", `collect failed for ${input.sourcePath}`), - }).pipe( - Effect.flatMap((collected) => - stableStringify(collected) - ? Effect.succeed(collected) - : Effect.fail( - new AppExecutorError({ - kind: "collect", - message: `collect failed for ${input.sourcePath}`, - }), - ), - ), - ); - -const invokeWithWorkerd = ( - bundle: string, - entry: { readonly toolName: string }, - input: unknown, - bridge: AppToolBridge, - limits: { readonly timeoutMs: number }, -): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const token = crypto.randomUUID(); - const options = { - mainModule: "driver.js", - modules: { "driver.js": appDriver(token), "app.js": bundle }, - hostToken: token, - toolBridge: bridge, - restartBackoffMs: 1, - } satisfies WorkerdModuleRunnerOptions; - const runner = createWorkerdModuleRunner(options); - try { - const response = await runner.run>( - { - op: "invoke", - toolName: entry.toolName, - input, - timeoutMs: limits.timeoutMs, - }, - limits.timeoutMs, - ); - if (!response.body.ok) throw toAppExecutorError(response.body, "invoke"); - return response.body.value; - } finally { - await runner.dispose(); - } - }, - catch: (cause) => mapCause(cause, "invoke", "app tool invocation failed"), - }); - -export const makeWorkerdAppToolExecutor = (): AppToolExecutor => ({ - collect: collectWithWorkerd, - invoke: invokeWithWorkerd, -}); diff --git a/packages/plugins/apps/src/git-client/hand.ts b/packages/plugins/apps/src/git-client/hand.ts deleted file mode 100644 index eaf26cb83c..0000000000 --- a/packages/plugins/apps/src/git-client/hand.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: git client returns stable ok/error envelopes before git-source maps to AppSourceError */ -// Hand-rolled shallow fetch. Orchestrates transport and packfile parsing. -import { resolveWant } from "./pktline"; -import { parsePack, walkTree, type ParsePackLimits } from "./packfile"; -import { authForHost, checkRefs, uploadPack, type AuthRecipe } from "./transport"; - -export interface FetchResult { - ok: boolean; - sha?: string; - resolvedRef?: string; - fileCount?: number; - totalBytes?: number; - packBytes?: number; - refsMs?: number; - fetchMs?: number; - parseMs?: number; - wallMs?: number; - truncated?: boolean; - files?: { path: string; bytes: Uint8Array }[]; - error?: string; -} - -export async function handFetch( - url: string, - ref?: string, - opts: { - token?: string; - maxBytes?: number; - fetchImpl?: typeof fetch; - parseLimits?: Partial; - allowPrivateHosts?: boolean; - } = {}, -): Promise { - const t0 = Date.now(); - try { - const host = new URL(url).host; - const auth: AuthRecipe = authForHost(host, opts.token); - const refs = await checkRefs(url, auth, opts.fetchImpl, { - allowPrivateHosts: opts.allowPrivateHosts, - }); - const { sha, resolvedRef } = resolveWant(refs.adv, ref); - - const up = await uploadPack(url, sha, { - auth, - fetchImpl: opts.fetchImpl, - maxBytes: opts.maxBytes, - allowPrivateHosts: opts.allowPrivateHosts, - }); - if (up.truncated) { - return { - ok: false, - sha, - resolvedRef, - packBytes: up.packBytes.length, - refsMs: refs.wallMs, - fetchMs: up.wallMs, - truncated: true, - wallMs: Date.now() - t0, - error: `byte cap hit at ${up.capUsed} bytes`, - }; - } - - const tp = Date.now(); - const parsed = await parsePack(up.packBytes, { limits: opts.parseLimits }); - const files = walkTree(parsed, sha); - const parseMs = Date.now() - tp; - - let totalBytes = 0; - for (const f of files) totalBytes += f.bytes.length; - - return { - ok: true, - sha, - resolvedRef, - fileCount: files.length, - totalBytes, - packBytes: up.packBytes.length, - refsMs: refs.wallMs, - fetchMs: up.wallMs, - parseMs, - wallMs: Date.now() - t0, - truncated: false, - files: files.map((f) => ({ path: f.path, bytes: f.bytes })), - }; - } catch (e) { - return { - ok: false, - error: String(e instanceof Error ? e.message : e), - wallMs: Date.now() - t0, - }; - } -} - -// refs-only cheap check -export async function handRefsCheck( - url: string, - ref?: string, - opts: { token?: string; fetchImpl?: typeof fetch; allowPrivateHosts?: boolean } = {}, -): Promise<{ - ok: boolean; - refCount?: number; - headSha?: string; - head?: string; - sha?: string; - resolvedRef?: string; - wallMs?: number; - error?: string; -}> { - const t0 = Date.now(); - try { - const host = new URL(url).host; - const auth = authForHost(host, opts.token); - const refs = await checkRefs(url, auth, opts.fetchImpl, { - allowPrivateHosts: opts.allowPrivateHosts, - }); - const head = refs.adv.headTarget; - const headSha = head ? refs.adv.refs.get(head) : refs.adv.refs.get("HEAD"); - const wanted = resolveWant(refs.adv, ref); - return { - ok: true, - refCount: refs.adv.refs.size, - head, - headSha, - sha: wanted.sha, - resolvedRef: wanted.resolvedRef, - wallMs: Date.now() - t0, - }; - } catch (e) { - return { - ok: false, - error: String(e instanceof Error ? e.message : e), - wallMs: Date.now() - t0, - }; - } -} diff --git a/packages/plugins/apps/src/git-client/packfile.ts b/packages/plugins/apps/src/git-client/packfile.ts deleted file mode 100644 index 9f55e40920..0000000000 --- a/packages/plugins/apps/src/git-client/packfile.ts +++ /dev/null @@ -1,609 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: pure packfile parser throws are caught by git-source and converted to AppSourceError */ -import { PUBLISH_LIMITS } from "../pipeline/publish"; - -// Packfile parser: header, object headers, zlib inflate, delta resolution, then -// commit-to-tree-to-blob walking. Pure Web APIs. - -const te = new TextEncoder(); -const td = new TextDecoder(); - -// git object type ids in packfiles -const OBJ_COMMIT = 1; -const OBJ_TREE = 2; -const OBJ_BLOB = 3; -const OBJ_TAG = 4; -const OBJ_OFS_DELTA = 6; -const OBJ_REF_DELTA = 7; - -export interface GitObject { - type: number; // resolved base type (1..4) - data: Uint8Array; -} - -export interface ParsePackLimits { - readonly maxObjectBytes: number; - readonly maxDeltaResultBytes: number; - readonly maxExpandedBytes: number; -} - -export interface ParsePackOptions { - readonly limits?: Partial; -} - -const DEFAULT_LIMITS: ParsePackLimits = { - maxObjectBytes: PUBLISH_LIMITS.maxFileBytes, - maxDeltaResultBytes: PUBLISH_LIMITS.maxFileBytes, - maxExpandedBytes: PUBLISH_LIMITS.maxTotalBytes, -}; - -const limitsFor = (options?: ParsePackOptions): ParsePackLimits => ({ - ...DEFAULT_LIMITS, - ...(options?.limits ?? {}), -}); - -const repositoryTooLarge = (message: string): Error => - new Error(`repository too large: ${message}`); - -class PackParseError extends Error { - constructor(message: string) { - super(`pack parse error: ${message}`); - } -} - -const requireAvailable = (buf: Uint8Array, pos: number, length: number, what: string): void => { - if (pos < 0 || length < 0 || pos + length > buf.length) { - throw new PackParseError(`truncated ${what}`); - } -}; - -const checkedAdd = (a: number, b: number, what: string): number => { - const value = a + b; - if (!Number.isSafeInteger(value)) throw new PackParseError(`${what} exceeds safe integer range`); - return value; -}; - -const checkedMul = (a: number, b: number, what: string): number => { - const value = a * b; - if (!Number.isSafeInteger(value)) throw new PackParseError(`${what} exceeds safe integer range`); - return value; -}; - -// sha1 over "type len\0data" to compute object id, needed to key REF_DELTA bases. -// Minimal sha1 (needed because Workers crypto.subtle is async; git object ids are sha1). -// We use crypto.subtle.digest("SHA-1") which IS available on workerd. -async function sha1Hex(bytes: Uint8Array): Promise { - const buf = await crypto.subtle.digest("SHA-1", bytes as BufferSource); - const arr = new Uint8Array(buf); - let s = ""; - for (const b of arr) s += b.toString(16).padStart(2, "0"); - return s; -} - -function gitObjectId(type: number, data: Uint8Array): Promise { - const typeName = - type === OBJ_COMMIT - ? "commit" - : type === OBJ_TREE - ? "tree" - : type === OBJ_BLOB - ? "blob" - : "tag"; - const header = te.encode(`${typeName} ${data.length}\0`); - const full = new Uint8Array(header.length + data.length); - full.set(header, 0); - full.set(data, header.length); - return sha1Hex(full); -} - -// Inflate a zlib stream starting at offset and report consumed input bytes. -// The pack parser needs the consumed length to advance to the next object. - -// --- Minimal INFLATE (RFC1951) + zlib wrapper, tracking bytes consumed --- -// Compact implementation; handles stored/fixed/dynamic Huffman. -class BitReader { - buf: Uint8Array; - pos: number; - bitBuf = 0; - bitCnt = 0; - constructor(buf: Uint8Array, pos: number) { - this.buf = buf; - this.pos = pos; - } - bit(): number { - if (this.bitCnt === 0) { - requireAvailable(this.buf, this.pos, 1, "deflate stream"); - this.bitBuf = this.buf[this.pos++]; - this.bitCnt = 8; - } - const b = this.bitBuf & 1; - this.bitBuf >>= 1; - this.bitCnt--; - return b; - } - bits(n: number): number { - let v = 0; - for (let i = 0; i < n; i++) v |= this.bit() << i; - return v; - } - align() { - this.bitCnt = 0; - } -} - -interface Huff { - counts: number[]; - symbols: number[]; -} -function buildHuff(lengths: number[], n: number): Huff { - const counts = new Array(16).fill(0); - for (let i = 0; i < n; i++) counts[lengths[i]]++; - counts[0] = 0; - const offsets = new Array(16).fill(0); - for (let i = 1; i < 16; i++) offsets[i] = offsets[i - 1] + counts[i - 1]; - const symbols = new Array(n).fill(0); - for (let i = 0; i < n; i++) if (lengths[i]) symbols[offsets[lengths[i]]++] = i; - return { counts, symbols }; -} -function decodeSym(br: BitReader, h: Huff): number { - let code = 0, - first = 0, - index = 0; - for (let len = 1; len <= 15; len++) { - code |= br.bit(); - const count = h.counts[len]; - if (code - first < count) return h.symbols[index + (code - first)]; - index += count; - first += count; - first <<= 1; - code <<= 1; - } - throw new PackParseError("bad huffman code"); -} -const LEN_BASE = [ - 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, - 163, 195, 227, 258, -]; -const LEN_EXTRA = [ - 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, -]; -const DIST_BASE = [ - 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, - 3073, 4097, 6145, 8193, 12289, 16385, 24577, -]; -const DIST_EXTRA = [ - 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, -]; -const CLEN_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]; - -// Inflate raw DEFLATE at buf[pos..], return {out, endPos} -function pushInflated(out: number[], byte: number, maxObjectBytes: number): void { - if (out.length + 1 > maxObjectBytes) { - throw repositoryTooLarge(`inflated object exceeds ${maxObjectBytes} bytes`); - } - out.push(byte); -} - -function inflateRaw( - buf: Uint8Array, - pos: number, - limits: Pick = DEFAULT_LIMITS, -): { out: Uint8Array; endPos: number } { - const br = new BitReader(buf, pos); - const out: number[] = []; - let final = 0; - do { - final = br.bit(); - const type = br.bits(2); - if (type === 0) { - br.align(); - requireAvailable(buf, br.pos, 4, "stored deflate block header"); - const len = buf[br.pos] | (buf[br.pos + 1] << 8); - const nlen = buf[br.pos + 2] | (buf[br.pos + 3] << 8); - if (((len ^ 0xffff) & 0xffff) !== nlen) { - throw new PackParseError("bad stored deflate length"); - } - br.pos += 4; // len + nlen - requireAvailable(buf, br.pos, len, "stored deflate block"); - for (let i = 0; i < len; i++) pushInflated(out, buf[br.pos++]!, limits.maxObjectBytes); - } else { - let litH: Huff, distH: Huff; - if (type === 1) { - const ll = new Array(288); - for (let i = 0; i < 144; i++) ll[i] = 8; - for (let i = 144; i < 256; i++) ll[i] = 9; - for (let i = 256; i < 280; i++) ll[i] = 7; - for (let i = 280; i < 288; i++) ll[i] = 8; - litH = buildHuff(ll, 288); - distH = buildHuff(new Array(30).fill(5), 30); - } else { - const hlit = br.bits(5) + 257; - const hdist = br.bits(5) + 1; - const hclen = br.bits(4) + 4; - const clen = new Array(19).fill(0); - for (let i = 0; i < hclen; i++) clen[CLEN_ORDER[i]] = br.bits(3); - const clH = buildHuff(clen, 19); - const lengths: number[] = []; - while (lengths.length < hlit + hdist) { - const sym = decodeSym(br, clH); - if (sym < 16) lengths.push(sym); - else if (sym === 16) { - const r = br.bits(2) + 3; - const prev = lengths[lengths.length - 1]; - for (let i = 0; i < r; i++) lengths.push(prev); - } else if (sym === 17) { - const r = br.bits(3) + 3; - for (let i = 0; i < r; i++) lengths.push(0); - } else { - const r = br.bits(7) + 11; - for (let i = 0; i < r; i++) lengths.push(0); - } - } - litH = buildHuff(lengths.slice(0, hlit), hlit); - distH = buildHuff(lengths.slice(hlit), hdist); - } - while (true) { - const sym = decodeSym(br, litH); - if (sym === 256) break; - if (sym < 256) pushInflated(out, sym, limits.maxObjectBytes); - else { - if (sym < 257 || sym > 285) throw new PackParseError(`bad length symbol: ${sym}`); - const l = LEN_BASE[sym - 257] + br.bits(LEN_EXTRA[sym - 257]); - const dsym = decodeSym(br, distH); - if (dsym < 0 || dsym >= DIST_BASE.length) { - throw new PackParseError(`bad distance symbol: ${dsym}`); - } - const dist = DIST_BASE[dsym] + br.bits(DIST_EXTRA[dsym]); - const start = out.length - dist; - if (start < 0) throw new PackParseError("deflate distance exceeds output size"); - for (let i = 0; i < l; i++) pushInflated(out, out[start + i]!, limits.maxObjectBytes); - } - } - } - } while (!final); - br.align(); - return { out: Uint8Array.from(out), endPos: br.pos }; -} - -function adler32(bytes: Uint8Array): number { - let a = 1; - let b = 0; - for (const byte of bytes) { - a = (a + byte) % 65521; - b = (b + a) % 65521; - } - return (b * 65536 + a) >>> 0; -} - -// zlib wrapper: 2-byte header, deflate, 4-byte adler32. Return inflated + end offset. -function inflateZlib( - buf: Uint8Array, - pos: number, - limits: Pick = DEFAULT_LIMITS, -): { out: Uint8Array; endPos: number } { - requireAvailable(buf, pos, 2, "zlib header"); - const cmf = buf[pos]!; - const flg = buf[pos + 1]!; - if ((cmf & 0x0f) !== 8) throw new PackParseError("unsupported zlib compression method"); - if ((cmf * 256 + flg) % 31 !== 0) throw new PackParseError("bad zlib header checksum"); - if ((flg & 0x20) !== 0) throw new PackParseError("zlib preset dictionaries are not supported"); - const r = inflateRaw(buf, pos + 2, limits); - requireAvailable(buf, r.endPos, 4, "zlib adler32 trailer"); - const expected = new DataView(buf.buffer, buf.byteOffset + r.endPos, 4).getUint32(0); - const actual = adler32(r.out); - if (expected !== actual) throw new PackParseError("bad zlib adler32"); - return { out: r.out, endPos: r.endPos + 4 }; -} - -// --- Packfile top-level parse --- -interface RawObj { - type: number; - data?: Uint8Array; // for non-delta - // delta: - baseOfs?: number; // absolute offset of base (OFS_DELTA) - baseRef?: string; // sha of base (REF_DELTA) - delta?: Uint8Array; - offset: number; -} - -function readVarintSize(buf: Uint8Array, pos: number): { type: number; size: number; pos: number } { - requireAvailable(buf, pos, 1, "object header"); - let c = buf[pos++]; - const type = (c >> 4) & 7; - let size = c & 15; - let multiplier = 16; - let bytes = 1; - while (c & 0x80) { - if (bytes >= 7) throw new PackParseError("object size varint is too long"); - requireAvailable(buf, pos, 1, "object size varint"); - c = buf[pos++]; - size = checkedAdd(size, checkedMul(c & 0x7f, multiplier, "object size"), "object size"); - multiplier = checkedMul(multiplier, 128, "object size multiplier"); - bytes += 1; - } - return { type, size, pos }; -} - -function readDeltaVarint( - delta: Uint8Array, - pos: number, - label: string, -): { value: number; pos: number } { - requireAvailable(delta, pos, 1, label); - let c = delta[pos++]!; - let value = c & 0x7f; - let multiplier = 128; - let bytes = 1; - while (c & 0x80) { - if (bytes >= 7) throw new PackParseError(`${label} varint is too long`); - requireAvailable(delta, pos, 1, label); - c = delta[pos++]!; - value = checkedAdd(value, checkedMul(c & 0x7f, multiplier, label), label); - multiplier = checkedMul(multiplier, 128, `${label} multiplier`); - bytes += 1; - } - return { value, pos }; -} - -function applyDelta( - base: Uint8Array, - delta: Uint8Array, - limits: Pick = DEFAULT_LIMITS, -): Uint8Array { - let p = 0; - const baseSize = readDeltaVarint(delta, p, "delta base size"); - p = baseSize.pos; - if (baseSize.value !== base.length) { - throw new PackParseError("delta base size does not match base object"); - } - const resSize = readDeltaVarint(delta, p, "delta result size"); - p = resSize.pos; - if (resSize.value > limits.maxDeltaResultBytes) { - throw repositoryTooLarge(`delta result exceeds ${limits.maxDeltaResultBytes} bytes`); - } - const out = new Uint8Array(resSize.value); - let o = 0; - while (p < delta.length) { - const op = delta[p++]; - if (op & 0x80) { - // copy from base - let cpOff = 0, - cpLen = 0; - if (op & 0x01) { - requireAvailable(delta, p, 1, "delta copy offset"); - cpOff += delta[p++]!; - } - if (op & 0x02) { - requireAvailable(delta, p, 1, "delta copy offset"); - cpOff += delta[p++]! * 256; - } - if (op & 0x04) { - requireAvailable(delta, p, 1, "delta copy offset"); - cpOff += delta[p++]! * 65536; - } - if (op & 0x08) { - requireAvailable(delta, p, 1, "delta copy offset"); - cpOff += delta[p++]! * 16777216; - } - if (op & 0x10) { - requireAvailable(delta, p, 1, "delta copy length"); - cpLen += delta[p++]!; - } - if (op & 0x20) { - requireAvailable(delta, p, 1, "delta copy length"); - cpLen += delta[p++]! * 256; - } - if (op & 0x40) { - requireAvailable(delta, p, 1, "delta copy length"); - cpLen += delta[p++]! * 65536; - } - if (cpLen === 0) cpLen = 0x10000; - requireAvailable(base, cpOff, cpLen, "delta copy source"); - requireAvailable(out, o, cpLen, "delta copy target"); - out.set(base.subarray(cpOff, cpOff + cpLen), o); - o += cpLen; - } else if (op) { - // insert `op` literal bytes - requireAvailable(delta, p, op, "delta insert"); - requireAvailable(out, o, op, "delta insert target"); - out.set(delta.subarray(p, p + op), o); - o += op; - p += op; - } else { - throw new PackParseError("bad delta opcode 0"); - } - } - if (o !== out.length) throw new PackParseError("delta result length does not match header"); - return out; -} - -export interface ParsedPack { - objects: Map; // sha -> resolved object - byOffset: Map; - count: number; -} - -// Parse a full packfile and resolve all deltas. Returns objects keyed by sha. -export async function parsePack(pack: Uint8Array, options?: ParsePackOptions): Promise { - const limits = limitsFor(options); - requireAvailable(pack, 0, 12, "pack header"); - if (td.decode(pack.subarray(0, 4)) !== "PACK") throw new PackParseError("not a packfile"); - const version = new DataView(pack.buffer, pack.byteOffset + 4, 4).getUint32(0); - if (version !== 2 && version !== 3) { - throw new PackParseError(`unsupported pack version: ${version}`); - } - const count = new DataView(pack.buffer, pack.byteOffset + 8, 4).getUint32(0); - let pos = 12; - let expandedBytes = 0; - - const accountExpanded = (size: number): void => { - expandedBytes = checkedAdd(expandedBytes, size, "expanded pack size"); - if (expandedBytes > limits.maxExpandedBytes) { - throw repositoryTooLarge(`expanded objects exceed ${limits.maxExpandedBytes} bytes`); - } - }; - - const raws: RawObj[] = []; - for (let i = 0; i < count; i++) { - const start = pos; - const { type, size, pos: p1 } = readVarintSize(pack, pos); - if (size > limits.maxObjectBytes && type !== OBJ_OFS_DELTA && type !== OBJ_REF_DELTA) { - throw repositoryTooLarge(`object declares ${size} bytes`); - } - pos = p1; - if (type === OBJ_OFS_DELTA) { - // negative offset varint - requireAvailable(pack, pos, 1, "ofs-delta base offset"); - let c = pack[pos++]!; - let ofs = c & 0x7f; - let bytes = 1; - while (c & 0x80) { - if (bytes >= 7) throw new PackParseError("ofs-delta offset varint is too long"); - requireAvailable(pack, pos, 1, "ofs-delta base offset"); - c = pack[pos++]!; - ofs = checkedAdd( - checkedMul(ofs + 1, 128, "ofs-delta offset"), - c & 0x7f, - "ofs-delta offset", - ); - bytes += 1; - } - const baseOfs = start - ofs; - if (baseOfs < 12 || baseOfs >= start) { - throw new PackParseError("ofs-delta base offset is invalid"); - } - const inf = inflateZlib(pack, pos, limits); - pos = inf.endPos; - accountExpanded(inf.out.length); - raws.push({ type, baseOfs, delta: inf.out, offset: start }); - } else if (type === OBJ_REF_DELTA) { - requireAvailable(pack, pos, 20, "ref-delta base sha"); - let hex = ""; - for (let j = 0; j < 20; j++) hex += pack[pos + j].toString(16).padStart(2, "0"); - pos += 20; - const inf = inflateZlib(pack, pos, limits); - pos = inf.endPos; - accountExpanded(inf.out.length); - raws.push({ type, baseRef: hex, delta: inf.out, offset: start }); - } else { - if (type < OBJ_COMMIT || type > OBJ_TAG) { - throw new PackParseError(`unsupported object type: ${type}`); - } - const inf = inflateZlib(pack, pos, limits); - pos = inf.endPos; - accountExpanded(inf.out.length); - raws.push({ type, data: inf.out, offset: start }); - } - } - requireAvailable(pack, pos, 20, "pack trailer"); - - // Resolve: first pass non-deltas, then iteratively resolve deltas. - const byOffset = new Map(); - const byRef = new Map(); - const objects = new Map(); - - // index base bytes by offset (for OFS) and we need sha for REF resolution. - const pending = [...raws]; - // resolve non-delta first - for (const r of pending) { - if (r.data && r.type <= OBJ_TAG) { - const obj: GitObject = { type: r.type, data: r.data }; - byOffset.set(r.offset, obj); - const sha = await gitObjectId(r.type, r.data); - byRef.set(sha, obj); - objects.set(sha, obj); - } - } - // resolve deltas iteratively - let remaining = pending.filter((r) => r.delta); - let progress = true; - while (remaining.length && progress) { - progress = false; - const still: RawObj[] = []; - for (const r of remaining) { - let base: GitObject | undefined; - if (r.baseOfs !== undefined) base = byOffset.get(r.baseOfs); - else if (r.baseRef) base = byRef.get(r.baseRef); - if (!base) { - still.push(r); - continue; - } - const resolved = applyDelta(base.data, r.delta!, limits); - accountExpanded(resolved.length); - const obj: GitObject = { type: base.type, data: resolved }; - byOffset.set(r.offset, obj); - const sha = await gitObjectId(base.type, resolved); - byRef.set(sha, obj); - objects.set(sha, obj); - progress = true; - } - remaining = still; - } - if (remaining.length) throw new PackParseError(`unresolved deltas: ${remaining.length}`); - - return { objects, byOffset, count, version } as ParsedPack; -} - -// --- Tree walking: commit sha -> file list --- -export interface TreeFile { - path: string; - mode: string; - sha: string; - bytes: Uint8Array; -} - -function parseCommit(data: Uint8Array): { tree: string } { - const text = td.decode(data); - const m = text.match(/^tree ([0-9a-f]{40})/m); - if (!m) throw new PackParseError("commit has no tree"); - return { tree: m[1] }; -} - -// Parse a tree object: entries of "modename\0<20-byte sha>" -function parseTree(data: Uint8Array): { mode: string; name: string; sha: string }[] { - const entries: { mode: string; name: string; sha: string }[] = []; - let p = 0; - while (p < data.length) { - let sp = p; - while (sp < data.length && data[sp] !== 0x20) sp++; - if (sp >= data.length) throw new PackParseError("malformed tree entry mode"); - const mode = td.decode(data.subarray(p, sp)); - let nul = sp + 1; - while (nul < data.length && data[nul] !== 0) nul++; - if (nul >= data.length) throw new PackParseError("malformed tree entry name"); - requireAvailable(data, nul + 1, 20, "tree entry sha"); - const name = td.decode(data.subarray(sp + 1, nul)); - let sha = ""; - for (let i = 0; i < 20; i++) sha += data[nul + 1 + i].toString(16).padStart(2, "0"); - p = nul + 21; - entries.push({ mode, name, sha }); - } - return entries; -} - -// Walk from commit sha to a flat file list. Blobs must be present in pack. -export function walkTree(parsed: ParsedPack, commitSha: string): TreeFile[] { - const commit = parsed.objects.get(commitSha); - if (!commit) throw new PackParseError(`commit ${commitSha} not in pack`); - const { tree } = parseCommit(commit.data); - const files: TreeFile[] = []; - const recur = (treeSha: string, prefix: string) => { - const t = parsed.objects.get(treeSha); - if (!t) throw new PackParseError(`tree ${treeSha} not in pack`); - for (const e of parseTree(t.data)) { - const full = prefix ? `${prefix}/${e.name}` : e.name; - if (e.mode === "40000" || e.mode === "040000") { - recur(e.sha, full); - } else if (e.mode === "160000") { - // gitlink/submodule; skip - } else { - const blob = parsed.objects.get(e.sha); - if (!blob) throw new PackParseError(`blob ${e.sha} (${full}) not in pack`); - files.push({ path: full, mode: e.mode, sha: e.sha, bytes: blob.data }); - } - } - }; - recur(tree, ""); - return files; -} - -export { inflateZlib, inflateRaw }; diff --git a/packages/plugins/apps/src/git-client/pktline.ts b/packages/plugins/apps/src/git-client/pktline.ts deleted file mode 100644 index 33d3b4d0ed..0000000000 --- a/packages/plugins/apps/src/git-client/pktline.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: pure git protocol parser throws are caught by git-source and converted to AppSourceError */ -// Shared pkt-line parsing + refs advertisement parse. -// Works in both Bun and workerd (no Node-isms; only Web APIs + Uint8Array). - -const td = new TextDecoder(); -const te = new TextEncoder(); - -export function pktLine(payload: string | Uint8Array): Uint8Array { - const body = typeof payload === "string" ? te.encode(payload) : payload; - const len = body.length + 4; - const hdr = te.encode(len.toString(16).padStart(4, "0")); - const out = new Uint8Array(hdr.length + body.length); - out.set(hdr, 0); - out.set(body, hdr.length); - return out; -} - -export const FLUSH = te.encode("0000"); -export const DELIM = te.encode("0001"); - -// Streaming pkt-line reader over a Uint8Array buffer. -export interface PktToken { - kind: "line" | "flush" | "delim"; - data?: Uint8Array; -} - -export function parsePktLines( - buf: Uint8Array, - options: { readonly requireComplete?: boolean } = {}, -): { tokens: PktToken[]; consumed: number } { - const tokens: PktToken[] = []; - let off = 0; - while (off + 4 <= buf.length) { - const lenHex = td.decode(buf.subarray(off, off + 4)); - const len = parseInt(lenHex, 16); - if (Number.isNaN(len)) throw new Error(`bad pkt-line length: ${lenHex}`); - if (len === 0) { - tokens.push({ kind: "flush" }); - off += 4; - continue; - } - if (len === 1) { - tokens.push({ kind: "delim" }); - off += 4; - continue; - } - if (len < 4) throw new Error(`bad pkt-line length: ${lenHex}`); - if (off + len > buf.length) break; // incomplete; caller supplies more - const data = buf.subarray(off + 4, off + len); - tokens.push({ kind: "line", data }); - off += len; - } - if (options.requireComplete === true && off !== buf.length) { - throw new Error("truncated pkt-line response"); - } - return { tokens, consumed: off }; -} - -// --- Refs advertisement (protocol v1 dumb-ish smart-http info/refs) --- - -export interface RefAdvertisement { - refs: Map; // refname -> sha - headTarget?: string; // symref HEAD target (e.g. refs/heads/main) - capabilities: string[]; - protocolVersion: number; -} - -// Parse the response of GET /info/refs?service=git-upload-pack (protocol v1). -export function parseInfoRefs(bytes: Uint8Array): RefAdvertisement { - const { tokens } = parsePktLines(bytes, { requireComplete: true }); - const refs = new Map(); - let capabilities: string[] = []; - let headTarget: string | undefined; - let sawService = false; - let first = true; - - for (const t of tokens) { - if (t.kind !== "line" || !t.data) continue; - let line = td.decode(t.data); - if (line.endsWith("\n")) line = line.slice(0, -1); - if (line.startsWith("# service=")) { - sawService = true; - continue; - } - // first ref line carries \0-separated capabilities - if (first) { - const nul = line.indexOf("\0"); - if (nul >= 0) { - capabilities = line - .slice(nul + 1) - .split(" ") - .filter(Boolean); - line = line.slice(0, nul); - } - first = false; - } - const sp = line.indexOf(" "); - if (sp < 0) continue; - const sha = line.slice(0, sp); - const name = line.slice(sp + 1); - if (/^[0-9a-f]{40,64}$/.test(sha)) refs.set(name, sha); - } - - // symref HEAD from capabilities: symref=HEAD:refs/heads/main - for (const cap of capabilities) { - if (cap.startsWith("symref=HEAD:")) headTarget = cap.slice("symref=HEAD:".length); - } - - return { refs, headTarget, capabilities, protocolVersion: sawService ? 1 : 1 }; -} - -// Resolve which sha a caller wants given a ref advertisement + optional ref name. -export function resolveWant( - adv: RefAdvertisement, - ref?: string, -): { sha: string; resolvedRef: string } { - if (ref) { - // try exact, then heads/, then tags/ - const candidates = [ref, `refs/heads/${ref}`, `refs/tags/${ref}`, `refs/${ref}`]; - for (const c of candidates) { - const sha = adv.refs.get(c); - if (sha) return { sha, resolvedRef: c }; - } - // maybe they passed a sha directly - if (/^[0-9a-f]{40,64}$/.test(ref)) return { sha: ref, resolvedRef: ref }; - throw new Error(`ref not found: ${ref}`); - } - // default: HEAD symref target, else HEAD ref, else first - if (adv.headTarget) { - const sha = adv.refs.get(adv.headTarget); - if (sha) return { sha, resolvedRef: adv.headTarget }; - } - const head = adv.refs.get("HEAD"); - if (head) return { sha: head, resolvedRef: "HEAD" }; - const first = adv.refs.entries().next(); - if (!first.done) return { sha: first.value[1], resolvedRef: first.value[0] }; - throw new Error("no refs advertised"); -} - -export { td, te }; diff --git a/packages/plugins/apps/src/git-client/transport.ts b/packages/plugins/apps/src/git-client/transport.ts deleted file mode 100644 index 5a6c50ecad..0000000000 --- a/packages/plugins/apps/src/git-client/transport.ts +++ /dev/null @@ -1,300 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: smart-HTTP transport errors are caught by git-source and converted to AppSourceError */ -// Smart-HTTP transport over fetch. Runtime-agnostic (Bun + workerd). -import { parseInfoRefs, type RefAdvertisement } from "./pktline"; -import { redactUrl, validateGitFetchUrl, type GitUrlPolicy } from "./url-security"; - -export interface AuthRecipe { - // Authorization header value, or undefined for anonymous. - authorization?: string; -} - -// Host-specific Basic auth recipes (verified against live hosts in this spike). -// github.com: Basic base64("x-access-token:" + TOKEN) -// gitlab.com: Basic base64("oauth2:" + TOKEN) -// bitbucket.org: Basic base64("x-token-auth:" + TOKEN) (app password: user:app_password) -// generic Gitea: Basic base64(username + ":" + TOKEN) -export function basicAuth(user: string, token: string): string { - return "Basic " + btoa(`${user}:${token}`); -} - -export function authForHost(host: string, token?: string): AuthRecipe { - if (!token) return {}; - if (host.includes("github.com")) return { authorization: basicAuth("x-access-token", token) }; - if (host.includes("gitlab.com")) return { authorization: basicAuth("oauth2", token) }; - if (host.includes("bitbucket.org")) return { authorization: basicAuth("x-token-auth", token) }; - // generic (Gitea/self-hosted): token as password with arbitrary username - return { authorization: basicAuth("git", token) }; -} - -function baseHeaders(auth: AuthRecipe): Record { - const h: Record = { - "user-agent": "git/2.x", - }; - if (auth.authorization) h.authorization = auth.authorization; - return h; -} - -function normalizeRepoUrl(url: string): string { - let u = url.trim(); - if (u.endsWith("/")) u = u.slice(0, -1); - if (!u.endsWith(".git")) u = u + ".git"; - return u; -} - -export interface RefsResult { - adv: RefAdvertisement; - status: number; - wallMs: number; - bytes: number; -} - -const MAX_REDIRECTS = 5; - -const fetchGit = async ( - rawUrl: string, - init: RequestInit, - fetchImpl: typeof fetch, - policy: GitUrlPolicy, -): Promise => { - const original = validateGitFetchUrl(rawUrl, policy); - let current = original; - for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) { - // Strip credentials on cross-host redirects (same posture as git/curl): - // a host must not be able to bounce our Authorization header elsewhere. - let headers = init.headers as Record | undefined; - if (headers?.authorization && current.host !== original.host) { - const { authorization: _dropped, ...rest } = headers; - headers = rest; - } - const response = await fetchImpl(current.toString(), { - ...init, - ...(headers ? { headers } : {}), - redirect: "manual", - }); - if (![301, 302, 303, 307, 308].includes(response.status)) return response; - const location = response.headers.get("location"); - if (!location) - throw new Error(`git redirect missing Location from ${redactUrl(current.toString())}`); - current = validateGitFetchUrl(new URL(location, current).toString(), policy); - } - throw new Error(`git redirect depth exceeded for ${redactUrl(rawUrl)}`); -}; - -// The cheap "did it change" check: GET info/refs. Returns the ref advertisement. -export async function checkRefs( - url: string, - auth: AuthRecipe = {}, - fetchImpl: typeof fetch = fetch, - policy: GitUrlPolicy = {}, -): Promise { - const repo = normalizeRepoUrl(url); - const infoUrl = `${repo}/info/refs?service=git-upload-pack`; - const t0 = Date.now(); - const res = await fetchGit( - infoUrl, - { - headers: { - ...baseHeaders(auth), - accept: "*/*", - }, - }, - fetchImpl, - policy, - ); - const buf = new Uint8Array(await res.arrayBuffer()); - const wallMs = Date.now() - t0; - if (!res.ok) { - throw new Error(`info/refs ${res.status}: ${new TextDecoder().decode(buf.subarray(0, 200))}`); - } - const ct = res.headers.get("content-type") || ""; - if (!ct.includes("git-upload-pack-advertisement")) { - // Some hosts (or a wrong URL) return HTML; treat as protocol failure. - throw new Error(`unexpected content-type: ${ct}`); - } - const adv = parseInfoRefs(buf); - return { adv, status: res.status, wallMs, bytes: buf.length }; -} - -export interface UploadPackOptions { - auth?: AuthRecipe; - fetchImpl?: typeof fetch; - // Abort the response body after this many bytes (byte cap). - maxBytes?: number; - useV2?: boolean; - allowPrivateHosts?: boolean; -} - -export interface UploadPackResult { - packBytes: Uint8Array; - status: number; - wallMs: number; - truncated: boolean; - capUsed: number; -} - -// Build a protocol-v1 upload-pack request body: want with caps, deepen 1, done. -import { FLUSH, pktLine } from "./pktline"; - -function buildV1Request(sha: string): Uint8Array { - const caps = "multi_ack side-band-64k thin-pack ofs-delta"; - const parts: Uint8Array[] = []; - parts.push(pktLine(`want ${sha} ${caps}\n`)); - parts.push(pktLine(`deepen 1\n`)); - parts.push(FLUSH); - parts.push(pktLine(`done\n`)); - let total = 0; - for (const p of parts) total += p.length; - const out = new Uint8Array(total); - let off = 0; - for (const p of parts) { - out.set(p, off); - off += p.length; - } - return out; -} - -// POST /git-upload-pack, parse side-band, extract raw packfile bytes. -// Handles side-band-64k: band 1 = pack data, band 2 = progress, band 3 = error. -export async function uploadPack( - url: string, - sha: string, - opts: UploadPackOptions = {}, -): Promise { - const fetchImpl = opts.fetchImpl ?? fetch; - const repo = normalizeRepoUrl(url); - const upUrl = `${repo}/git-upload-pack`; - const body = buildV1Request(sha); - const t0 = Date.now(); - const res = await fetchGit( - upUrl, - { - method: "POST", - headers: { - ...baseHeaders(opts.auth ?? {}), - "content-type": "application/x-git-upload-pack-request", - accept: "application/x-git-upload-pack-result", - "git-protocol": "version=1", - }, - body: new Uint8Array(body).buffer, - }, - fetchImpl, - { allowPrivateHosts: opts.allowPrivateHosts }, - ); - if (!res.ok) { - const errText = await res.text(); - throw new Error(`upload-pack ${res.status}: ${errText.slice(0, 200)}`); - } - - // Stream + demux side-band pkt-lines, with a byte cap on total downloaded. - const reader = res.body!.getReader(); - const maxBytes = opts.maxBytes ?? Infinity; - let downloaded = 0; - let truncated = false; - - // Accumulate raw bytes, then demux pkt-lines. We buffer because pkt-lines can - // straddle chunk boundaries; we cap the RAW download. - const chunks: Uint8Array[] = []; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) { - downloaded += value.length; - chunks.push(value); - if (downloaded >= maxBytes) { - truncated = true; - await reader.cancel(); - break; - } - } - } - const wallMs = Date.now() - t0; - - // concat - const raw = new Uint8Array(downloaded); - { - let off = 0; - for (const c of chunks) { - raw.set(c.subarray(0, Math.min(c.length, raw.length - off)), off); - off += c.length; - if (off >= raw.length) break; - } - } - - // Demux side-band pkt-lines into packfile bytes. - const pack = demuxSideBand(raw); - return { packBytes: pack, status: res.status, wallMs, truncated, capUsed: downloaded }; -} - -const tdLocal = new TextDecoder(); - -function demuxSideBand(raw: Uint8Array): Uint8Array { - const packChunks: Uint8Array[] = []; - let off = 0; - - // Phase 1: consume the shallow/ACK/NAK preamble. These pkt-lines are NOT - // side-band framed. The preamble ends at the first flush pkt (0000) that - // separates negotiation from the packfile stream. GitHub sends: - // shallow \n 0000 NAK\n - // GitLab/others may send just NAK\n before side-band with no flush; so we - // also stop the preamble as soon as a line's first byte is a valid band id - // (1/2/3) AND the remainder doesn't look like a git control word. - const isControlLine = (payload: Uint8Array): boolean => { - const s = tdLocal.decode(payload.subarray(0, Math.min(payload.length, 8))); - return ( - s.startsWith("shallow ") || - s.startsWith("unshallow") || - s.startsWith("ACK") || - s.startsWith("NAK") || - s.startsWith("ERR ") - ); - }; - - while (off + 4 <= raw.length) { - const len = parseInt(tdLocal.decode(raw.subarray(off, off + 4)), 16); - if (Number.isNaN(len)) throw new Error("bad pkt-line length in upload-pack response"); - if (len === 0) { - off += 4; - // A flush during preamble ends the negotiation section; keep going into side-band. - continue; - } - if (len === 1) { - off += 4; - continue; - } - if (len < 4) throw new Error("bad pkt-line length in upload-pack response"); - if (off + len > raw.length) throw new Error("truncated upload-pack response"); - const payload = raw.subarray(off + 4, off + len); - if (payload.length === 0) { - off += len; - continue; - } - if (isControlLine(payload)) { - off += len; // skip negotiation line - continue; - } - // side-band framed from here on - const band = payload[0]; - const data = payload.subarray(1); - off += len; - if (band === 1) { - packChunks.push(data); - } else if (band === 2) { - // progress; ignore - } else if (band === 3) { - throw new Error("remote error: " + tdLocal.decode(data)); - } else { - // Not side-band framed at all (side-band not negotiated): whole payload is pack. - packChunks.push(payload); - } - } - if (off !== raw.length) throw new Error("truncated upload-pack response"); - let total = 0; - for (const c of packChunks) total += c.length; - const out = new Uint8Array(total); - let o = 0; - for (const c of packChunks) { - out.set(c, o); - o += c.length; - } - return out; -} diff --git a/packages/plugins/apps/src/git-client/url-security.ts b/packages/plugins/apps/src/git-client/url-security.ts deleted file mode 100644 index d99127e675..0000000000 --- a/packages/plugins/apps/src/git-client/url-security.ts +++ /dev/null @@ -1,154 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: URL parsing helpers throw into Effect and Promise adapters */ -export interface GitUrlPolicy { - readonly allowPrivateHosts?: boolean; -} - -const TOKEN_QUERY_KEYS = - /(?:token|access[_-]?token|auth|authorization|key|secret|password|credential)/i; - -export const redactUrl = (input: string): string => { - try { - const url = new URL(input); - url.username = ""; - url.password = ""; - url.search = ""; - url.hash = ""; - return url.toString(); - } catch { - return input.replace(/\/\/[^/@\s]+@/g, "//").replace(/[?#].*$/g, ""); - } -}; - -export const hasTokenLikeQuery = (url: URL): boolean => { - for (const key of url.searchParams.keys()) { - if (TOKEN_QUERY_KEYS.test(key)) return true; - } - return false; -}; - -const normalizeHost = (host: string): string => host.replace(/^\[|\]$/g, "").toLowerCase(); - -const parseDecimalIpv4 = (host: string): string | null => { - if (!/^[0-9]+$/.test(host)) return null; - const value = Number(host); - if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) return null; - return [ - Math.floor(value / 16777216) & 255, - Math.floor(value / 65536) & 255, - Math.floor(value / 256) & 255, - value & 255, - ].join("."); -}; - -const parseSegmentedIpv4 = (host: string): string | null => { - const parts = host.split("."); - if (parts.length < 2 || parts.length > 4) return null; - const nums = parts.map((part) => { - if (/^0x[0-9a-f]+$/i.test(part)) return Number.parseInt(part.slice(2), 16); - if (/^0[0-7]+$/.test(part)) return Number.parseInt(part.slice(1), 8); - if (/^[0-9]+$/.test(part)) return Number.parseInt(part, 10); - return Number.NaN; - }); - if (nums.some((part) => !Number.isInteger(part) || part < 0)) return null; - const maxTail = [0, 0xffffff, 0xffff, 0xff][parts.length - 1] ?? 0xff; - if (nums.slice(0, -1).some((part) => part > 255) || nums[nums.length - 1]! > maxTail) { - return null; - } - let value = 0; - for (let index = 0; index < nums.length - 1; index += 1) { - value = value * 256 + nums[index]!; - } - const tailBytes = 5 - nums.length; - value = value * 256 ** tailBytes + nums[nums.length - 1]!; - return [ - Math.floor(value / 16777216) & 255, - Math.floor(value / 65536) & 255, - Math.floor(value / 256) & 255, - value & 255, - ].join("."); -}; - -const canonicalIpv4 = (host: string): string | null => { - const lower = host.toLowerCase(); - if (/^[0-9]+$/.test(lower)) return parseDecimalIpv4(lower); - if (/^(?:0x[0-9a-f]+|0[0-7]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|0[0-7]+|[0-9]+)){1,3}$/i.test(lower)) { - return parseSegmentedIpv4(lower); - } - if (/^[0-9]{1,3}(?:\.[0-9]{1,3}){3}$/.test(lower)) return lower; - return null; -}; - -const ipv4FromMappedIpv6 = (host: string): string | null => { - const normalized = normalizeHost(host); - if (!normalized.startsWith("::ffff:")) return null; - const tail = normalized.slice("::ffff:".length); - if (tail.includes(".")) return canonicalIpv4(tail); - if (tail.includes(":")) { - const parts = tail.split(":"); - if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/i.test(part))) return null; - const value = Number.parseInt(parts[0]!, 16) * 65536 + Number.parseInt(parts[1]!, 16); - return [ - Math.floor(value / 16777216) & 255, - Math.floor(value / 65536) & 255, - Math.floor(value / 256) & 255, - value & 255, - ].join("."); - } - const compact = tail.replace(/:/g, ""); - if (!/^[0-9a-f]{8}$/i.test(compact)) return null; - const value = Number.parseInt(compact, 16); - return [ - Math.floor(value / 16777216) & 255, - Math.floor(value / 65536) & 255, - Math.floor(value / 256) & 255, - value & 255, - ].join("."); -}; - -const isPrivateIpv4 = (host: string): boolean => { - const canonical = canonicalIpv4(host); - if (!canonical) return false; - const nums = canonical.split(".").map((part) => Number(part)); - const [a, b] = nums; - return ( - a === 10 || - a === 127 || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) || - (a === 169 && b === 254) || - a === 0 - ); -}; - -export const isPrivateGitHost = (host: string): boolean => { - const normalized = normalizeHost(host); - const mapped = ipv4FromMappedIpv6(normalized); - return ( - normalized === "localhost" || - normalized.endsWith(".localhost") || - normalized === "::1" || - normalized.startsWith("fe80:") || - normalized.startsWith("fc") || - normalized.startsWith("fd") || - isPrivateIpv4(mapped ?? normalized) - ); -}; - -export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => { - const url = new URL(input); - const privateHost = isPrivateGitHost(url.hostname); - if ( - url.protocol !== "https:" && - !(url.protocol === "http:" && policy.allowPrivateHosts === true && privateHost) - ) { - throw new Error("git source URL must use https"); - } - if (url.username || url.password) - throw new Error("git source URL must not include embedded credentials"); - if (hasTokenLikeQuery(url)) - throw new Error("git source URL must not include token query parameters"); - if (policy.allowPrivateHosts !== true && privateHost) { - throw new Error("git source URL host is not allowed"); - } - return url; -}; diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts deleted file mode 100644 index 15eae167c6..0000000000 --- a/packages/plugins/apps/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./api"; -export * from "./authoring"; -export { makeWorkerdAppToolExecutor } from "./executor/workerd-app-tool-executor"; -export { makeDynamicWorkerAppToolExecutor } from "./executor/dynamic-worker-app-tool-executor"; -export { makeWorkerBundlerBackend } from "./pipeline/worker-bundler"; -export { makeDynamicWorkerBundlerBackend } from "./pipeline/dynamic-worker-bundler"; -export { WORKER_BUNDLER_VERSION } from "./pipeline/worker-bundler-version"; diff --git a/packages/plugins/apps/src/pipeline/bundle.ts b/packages/plugins/apps/src/pipeline/bundle.ts deleted file mode 100644 index a171e095b0..0000000000 --- a/packages/plugins/apps/src/pipeline/bundle.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Effect } from "effect"; - -import { AppExecutorError } from "../executor/app-tool-executor"; -import type { ToolchainRef } from "./descriptor"; - -export interface BundleInput { - readonly files: ReadonlyMap; - readonly entry: string; -} - -export interface BundleOutput { - readonly code: string; - readonly toolchain?: ToolchainRef; -} - -export interface BundleBackend { - readonly bundle: (input: BundleInput) => Effect.Effect; - readonly toolchain: () => ToolchainRef; -} - -// The in-process esbuild backend must load lazily: esbuild's Node launcher -// reads process.versions at module scope, which the Cloudflare Workers -// runtime rejects at deploy validation. Hosts inject their own backend -// (native worker-bundler on cloud, workerd worker-bundler on selfhost); only -// Node-side tests and bare publish() calls reach this import. -export const defaultBundleBackend = (): Effect.Effect => - Effect.tryPromise({ - try: () => import("./esbuild-bundler").then((module) => module.inProcessBundleBackend()), - catch: (cause) => - new AppExecutorError({ - kind: "bundle", - message: "in-process esbuild bundle backend is unavailable in this runtime", - cause, - }), - }); - -export const bundleEntry = ( - input: BundleInput, - backend?: BundleBackend, -): Effect.Effect => - backend - ? backend.bundle(input) - : defaultBundleBackend().pipe(Effect.flatMap((resolved) => resolved.bundle(input))); diff --git a/packages/plugins/apps/src/pipeline/bundler-driver.ts b/packages/plugins/apps/src/pipeline/bundler-driver.ts deleted file mode 100644 index eed9673b84..0000000000 --- a/packages/plugins/apps/src/pipeline/bundler-driver.ts +++ /dev/null @@ -1,194 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-json-parse, executor/no-instanceof-tagged-error -- boundary: worker-bundler driver and package validation convert toolchain failures into typed AppExecutorError */ -import { AppExecutorError } from "../executor/app-tool-executor"; -import type { BundleInput } from "./bundle"; -import { PUBLISH_LIMITS } from "./publish"; - -const textEncoder = new TextEncoder(); - -export const executorAppSource = ` -const makeIntegrationDeclaration = (state) => Object.freeze({ - kind: "integration", - slug: state.slug, - mode: state.mode, - ...(state.description !== undefined ? { description: state.description } : {}), - array: () => makeIntegrationDeclaration({ ...state, mode: "many" }), - describe: (text) => makeIntegrationDeclaration({ ...state, description: text }), -}); -export const integration = (slug) => makeIntegrationDeclaration({ slug, mode: "one" }); -export const defineTool = (definition) => ({ ...definition, "~executorAppTool": true }); -`; - -export const DEFAULT_REGISTRY_ORIGINS = ["https://registry.npmjs.org"] as const; - -export const registryOriginsFromEnv = (): readonly string[] => [ - ...DEFAULT_REGISTRY_ORIGINS, - ...(process.env.EXECUTOR_NPM_REGISTRY ? [new URL(process.env.EXECUTOR_NPM_REGISTRY).origin] : []), -]; - -export const driverModule = (options: { - readonly token?: string; - readonly registryOrigins: readonly string[]; -}): string => ` -import { createWorker } from "./worker-bundler.js"; - -const allowedRegistryOrigins = new Set(${JSON.stringify(options.registryOrigins)}); -const originalFetch = globalThis.fetch.bind(globalThis); -globalThis.fetch = async (input, init) => { - const url = new URL(typeof input === "string" ? input : input.url); - if (!allowedRegistryOrigins.has(url.origin)) { - throw new Error("publish worker outbound fetch blocked: " + url.origin); - } - return originalFetch(input, init); -}; - -const executorAppSource = ${JSON.stringify(executorAppSource)}; -const json = (body, status = 200) => Response.json(body, { status }); -const moduleCode = (module) => { - if (typeof module === "string") return module; - if (module && typeof module.js === "string") return module.js; - if (module && typeof module.cjs === "string") return module.cjs; - return null; -}; - -export default { - async fetch(request) { - ${ - options.token - ? `if (request.url.endsWith("/__health")) return json({ ok: true, runnerToken: ${JSON.stringify(options.token)} });` - : "" - } - if (!request.url.endsWith("/run")) return json({ ok: false, message: "not found" }, 404); - try { - const input = await request.json(); - const files = { ...input.files }; - files["__executor_entry.ts"] = "import artifact from " + JSON.stringify("./" + input.entry) + ";\\nexport default artifact;\\n"; - const result = await createWorker({ - files, - entryPoint: "__executor_entry.ts", - bundle: true, - target: "es2022", - minify: false, - jsx: "automatic", - conditions: ["workerd", "worker", "browser", "import", "default"], - virtualModules: { "executor:app": executorAppSource }, - }); - const code = moduleCode(result.modules[result.mainModule]); - if (code === null) { - return json({ ok: false, message: "worker-bundler did not return JavaScript for " + result.mainModule }); - } - const installFailure = (result.warnings ?? []).find((warning) => /failed to install/i.test(String(warning))); - if (installFailure) { - return json({ ok: false, message: String(installFailure) }); - } - for (const [path, module] of Object.entries(result.modules)) { - const source = moduleCode(module) ?? ""; - if (/\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(path) || /\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(source)) { - return json({ ok: false, message: "package dependency includes unsupported native module artifact: " + path }); - } - } - return json({ ok: true, code, warnings: result.warnings ?? [] }); - } catch (error) { - return json({ ok: false, message: error && error.message ? error.message : String(error) }); - } - }, -}; -`; - -const blockedDependencySpec = (spec: string): boolean => - /^(?:https?:|git(?:\+|:)|file:|workspace:|link:|portal:)/.test(spec); - -export const packageBoundaryError = (input: BundleInput): AppExecutorError | null => { - for (const path of input.files.keys()) { - if (/\.node$|(?:^|\/)(?:binding\.gyp|node-gyp|prebuilds?|node_modules\/.*\.node)/.test(path)) { - return new AppExecutorError({ - kind: "bundle", - message: `package dependency includes unsupported native module artifact: ${path}`, - diagnostics: [ - { path, message: "native modules are not supported in app publish dependencies" }, - ], - }); - } - } - const rawPackageJson = input.files.get("package.json"); - if (rawPackageJson === undefined) return null; - try { - const parsed = JSON.parse(rawPackageJson) as { - readonly name?: unknown; - readonly scripts?: unknown; - readonly dependencies?: unknown; - readonly devDependencies?: unknown; - readonly optionalDependencies?: unknown; - readonly peerDependencies?: unknown; - }; - const scripts = - parsed.scripts !== null && - typeof parsed.scripts === "object" && - !Array.isArray(parsed.scripts) - ? (parsed.scripts as Record) - : {}; - const blocked = Object.keys(scripts).find((name) => - /^(pre|post)?install$|^prepare$/.test(name), - ); - const packageName = typeof parsed.name === "string" ? parsed.name : "package.json"; - if (blocked) { - return new AppExecutorError({ - kind: "bundle", - message: `package ${packageName} declares unsupported lifecycle script "${blocked}"`, - diagnostics: [ - { - path: "package.json", - message: `lifecycle script "${blocked}" is not allowed in app publish dependencies`, - }, - ], - }); - } - for (const field of [ - "dependencies", - "devDependencies", - "optionalDependencies", - "peerDependencies", - ] as const) { - const deps = parsed[field]; - if (deps === null || typeof deps !== "object" || Array.isArray(deps)) continue; - for (const [name, spec] of Object.entries(deps as Record)) { - if (typeof spec === "string" && blockedDependencySpec(spec)) { - return new AppExecutorError({ - kind: "bundle", - message: `package dependency ${name} uses unsupported non-registry spec "${spec}"`, - diagnostics: [ - { - path: "package.json", - message: `dependency ${name} must resolve from an allowed npm registry`, - }, - ], - }); - } - } - } - return null; - } catch (cause) { - return new AppExecutorError({ - kind: "bundle", - message: "package.json is not valid JSON", - diagnostics: [{ path: "package.json", message: "invalid package.json" }], - cause, - }); - } -}; - -export const fileRecord = (files: ReadonlyMap): Record => { - const out: Record = {}; - for (const [path, source] of files) out[path] = source; - return out; -}; - -export const enforceOutputLimit = (entry: string, code: string): AppExecutorError | null => { - const size = textEncoder.encode(code).byteLength; - return size <= PUBLISH_LIMITS.maxTotalBytes - ? null - : new AppExecutorError({ - kind: "bundle", - message: `bundle for ${entry} is ${size} bytes, exceeding the limit of ${PUBLISH_LIMITS.maxTotalBytes} bytes`, - diagnostics: [{ path: entry, message: "bundle exceeds publish size limit" }], - }); -}; diff --git a/packages/plugins/apps/src/pipeline/descriptor.ts b/packages/plugins/apps/src/pipeline/descriptor.ts deleted file mode 100644 index bd5c63f5af..0000000000 --- a/packages/plugins/apps/src/pipeline/descriptor.ts +++ /dev/null @@ -1,75 +0,0 @@ -export const DESCRIPTOR_VERSION = 6 as const; - -export interface ModuleSourceRef { - readonly path: string; - readonly sourceHash: string; -} - -export interface ToolchainRef { - readonly bundler: { - readonly name: string; - readonly version: string; - }; - readonly executor: { - readonly name: string; - readonly version: string; - }; - readonly target: string; -} - -export interface IntegrationDecl { - readonly slug: string; - readonly mode: "one" | "many"; - readonly description?: string; -} - -export interface ToolDescriptor { - readonly name: string; - readonly sourcePath: string; - readonly bundleKey: string; - readonly source: ModuleSourceRef; - readonly description: string; - readonly integrations: Readonly>; - readonly inputSchema?: unknown; - readonly outputSchema?: unknown; - readonly annotations?: { - readonly readOnly?: boolean; - readonly destructive?: boolean; - readonly requiresApproval?: boolean; - }; -} - -export interface DeferredDescriptor { - readonly path: string; - readonly reason: "not supported yet"; -} - -export interface AppDescriptor { - readonly version: typeof DESCRIPTOR_VERSION; - readonly app: string; - readonly sourceRef: string; - readonly publishedAt: number; - readonly toolchain: ToolchainRef; - readonly tools: readonly ToolDescriptor[]; - readonly workflows: readonly DeferredDescriptor[]; - readonly ui: readonly DeferredDescriptor[]; - readonly skills: readonly DeferredDescriptor[]; - readonly skipped: readonly DeferredDescriptor[]; -} - -export const stableStringify = (value: unknown): string => { - const seen = new WeakSet(); - const walk = (v: unknown): unknown => { - if (v === null || typeof v !== "object") return v; - if (seen.has(v)) return "[Circular]"; - seen.add(v); - if (Array.isArray(v)) return v.map(walk); - const out: Record = {}; - for (const key of Object.keys(v as Record).sort()) { - const inner = (v as Record)[key]; - if (inner !== undefined) out[key] = walk(inner); - } - return out; - }; - return JSON.stringify(walk(value)); -}; diff --git a/packages/plugins/apps/src/pipeline/discover.ts b/packages/plugins/apps/src/pipeline/discover.ts deleted file mode 100644 index 267823a72c..0000000000 --- a/packages/plugins/apps/src/pipeline/discover.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Data } from "effect"; - -export interface FileDiagnostic { - readonly path: string; - readonly message: string; -} - -export class PublishError extends Data.TaggedError("PublishError")<{ - readonly message: string; - readonly stage: "discover" | "bundle" | "collect" | "project"; - readonly diagnostics: readonly FileDiagnostic[]; -}> {} - -export interface DiscoveredTool { - readonly name: string; - readonly entry: string; -} - -export interface SkippedArtifact { - readonly path: string; - readonly reason: "not supported yet"; -} - -export interface DiscoverResult { - readonly tools: readonly DiscoveredTool[]; - readonly skipped: readonly SkippedArtifact[]; -} - -const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/; -const DEFERRED_RE = /^(workflows|ui|skills)\//; - -/** Reserved top-level app source folders whose contents are deferred by discovery. */ -export const RESERVED_ARTIFACT_DIRS = ["workflows", "ui", "skills"] as const; - -/** Returns the discovered tool key for a path when it matches the canonical tool layout. */ -export const toolKeyFromPath = (path: string): string | null => path.match(TOOL_RE)?.[1] ?? null; - -/** Returns true when a path is under a reserved app source folder. */ -export const isReservedArtifactPath = (path: string): boolean => DEFERRED_RE.test(path); - -export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key); - -export const discover = (files: ReadonlyMap): DiscoverResult | PublishError => { - const diagnostics: FileDiagnostic[] = []; - const tools: DiscoveredTool[] = []; - const skipped: SkippedArtifact[] = []; - const seen = new Set(); - - for (const [path] of files) { - if (path === "executor.json") continue; - const tool = toolKeyFromPath(path); - if (tool) { - const name = tool; - if (seen.has(name)) { - diagnostics.push({ path, message: `duplicate tool identity: ${name}` }); - } else { - seen.add(name); - tools.push({ name, entry: path }); - } - continue; - } - if (path.startsWith("tools/")) { - diagnostics.push({ path, message: "file does not match the expected layout for tools/" }); - continue; - } - if (isReservedArtifactPath(path)) skipped.push({ path, reason: "not supported yet" }); - } - - if (diagnostics.length > 0) { - return new PublishError({ - message: `discover found ${diagnostics.length} problem(s)`, - stage: "discover", - diagnostics, - }); - } - return { tools, skipped }; -}; diff --git a/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.ts b/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.ts deleted file mode 100644 index 18f2a2dcd1..0000000000 --- a/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-unknown-error-message, executor/no-instanceof-tagged-error, executor/no-instanceof-error -- boundary: Worker Loader adapter converts bundler Worker failures into typed AppExecutorError */ -import { Effect } from "effect"; - -import { AppExecutorError } from "../executor/app-tool-executor"; -import type { BundleBackend, BundleOutput } from "./bundle"; -import { - driverModule, - enforceOutputLimit, - fileRecord, - packageBoundaryError, - registryOriginsFromEnv, -} from "./bundler-driver"; -import type { ToolchainRef } from "./descriptor"; -import { WORKER_BUNDLER_ESBUILD_VERSION, WORKER_BUNDLER_VERSION } from "./worker-bundler-version"; - -type DynamicWorkerDefinition = { - readonly compatibilityDate: string; - readonly compatibilityFlags?: readonly string[]; - readonly mainModule: string; - readonly modules: Readonly>; -}; - -export type DynamicWorkerLoaderCompatible = { - readonly load?: (code: DynamicWorkerDefinition) => { readonly getEntrypoint: () => unknown }; - readonly get?: ( - name: string | null, - factory: () => DynamicWorkerDefinition, - ) => { readonly getEntrypoint: () => unknown }; -}; - -export interface DynamicWorkerBundlerArtifact { - readonly source: string; - readonly wasm: ArrayBuffer; -} - -export interface DynamicWorkerBundlerBackendOptions { - readonly loader: DynamicWorkerLoaderCompatible; - readonly artifact: () => Promise; -} - -type BundlerResponse = - | { readonly ok: true; readonly code: string; readonly warnings?: readonly unknown[] } - | { readonly ok: false; readonly message?: string }; - -type FetchEntrypoint = { - readonly fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; -}; - -type LoadedBundlerWorker = { - readonly stub: { readonly getEntrypoint: () => unknown }; - readonly entrypoint: FetchEntrypoint; -}; - -const asFetchEntrypoint = (value: unknown): FetchEntrypoint => value as FetchEntrypoint; - -const loadBundlerWorker = ( - loader: DynamicWorkerLoaderCompatible, - artifact: DynamicWorkerBundlerArtifact, -): LoadedBundlerWorker => { - const definition: DynamicWorkerDefinition = { - compatibilityDate: "2025-06-01", - compatibilityFlags: ["nodejs_compat"], - mainModule: "driver.js", - modules: { - "driver.js": driverModule({ registryOrigins: registryOriginsFromEnv() }), - "worker-bundler.js": artifact.source, - "esbuild.wasm": { wasm: artifact.wasm }, - }, - }; - const worker = - loader.load !== undefined - ? loader.load(definition) - : loader.get?.(`app-bundler-${crypto.randomUUID()}`, () => definition); - if (worker === undefined) { - throw new AppExecutorError({ - kind: "bundle", - message: "Worker Loader binding does not support load() or get()", - }); - } - return { stub: worker, entrypoint: asFetchEntrypoint(worker.getEntrypoint()) }; -}; - -const toolchain = (): ToolchainRef => ({ - bundler: { - name: "@cloudflare/worker-bundler", - version: `${WORKER_BUNDLER_VERSION} (esbuild-wasm ${WORKER_BUNDLER_ESBUILD_VERSION})`, - }, - executor: { name: "cloud-worker", version: "worker-loader" }, - target: "es2022", -}); - -export const makeDynamicWorkerBundlerBackend = ( - options: DynamicWorkerBundlerBackendOptions, -): BundleBackend => { - let artifactPromise: Promise | undefined; - const artifact = () => { - artifactPromise ??= options.artifact(); - return artifactPromise; - }; - return { - toolchain, - bundle: (input): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const boundaryError = packageBoundaryError(input); - if (boundaryError) throw boundaryError; - const worker = loadBundlerWorker(options.loader, await artifact()); - const response = await worker.entrypoint.fetch("https://executor-bundler.local/run", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ files: fileRecord(input.files), entry: input.entry }), - }); - const body = (await response.json()) as BundlerResponse; - if (!body.ok) { - const message = body.message ?? `worker-bundler failed for ${input.entry}`; - throw new AppExecutorError({ - kind: "bundle", - message, - diagnostics: [{ path: input.entry, message }], - }); - } - const limitError = enforceOutputLimit(input.entry, body.code); - if (limitError) throw limitError; - return { code: body.code, toolchain: toolchain() }; - }, - catch: (cause) => - cause instanceof AppExecutorError - ? cause - : new AppExecutorError({ - kind: "bundle", - message: `worker-bundler failed for ${input.entry}: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - cause, - }), - }), - }; -}; diff --git a/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.worker.test.ts b/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.worker.test.ts deleted file mode 100644 index c9d34c659e..0000000000 --- a/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.worker.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -/* oxlint-disable executor/no-unknown-error-message -- test boundary: asserting the typed AppExecutorError message */ -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { env } from "cloudflare:workers"; - -import { AppExecutorError } from "../executor/app-tool-executor"; -import { bundleEntry } from "./bundle"; -import { makeDynamicWorkerBundlerBackend } from "./dynamic-worker-bundler"; - -declare const __WORKER_BUNDLER_SOURCE__: string; -declare const __WORKER_BUNDLER_WASM_BASE64__: string; - -interface WorkerLoader { - readonly load: (code: { - readonly compatibilityDate: string; - readonly compatibilityFlags?: readonly string[]; - readonly mainModule: string; - readonly modules: Record; - }) => { readonly getEntrypoint: () => { readonly fetch: typeof fetch } }; - readonly get: ( - name: string | null, - factory: () => { - readonly compatibilityDate: string; - readonly compatibilityFlags?: readonly string[]; - readonly mainModule: string; - readonly modules: Record; - }, - ) => { readonly getEntrypoint: () => { readonly fetch: typeof fetch } }; -} - -const base64ToArrayBuffer = (value: string): ArrayBuffer => { - const bytes = Uint8Array.from(atob(value), (char) => char.charCodeAt(0)); - return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); -}; - -const files = (entries: readonly (readonly [string, string])[]): ReadonlyMap => - new Map(entries); - -const artifact = async () => ({ - source: __WORKER_BUNDLER_SOURCE__, - wasm: base64ToArrayBuffer(__WORKER_BUNDLER_WASM_BASE64__), -}); - -const getOnly = (loader: WorkerLoader): WorkerLoader => ({ - load: undefined as never, - get: (name, factory) => loader.get(name, factory), -}); - -const driver = ` -import { createWorker } from "./worker-bundler.js"; - -const moduleCode = (module) => { - if (typeof module === "string") return module; - if (module && typeof module.js === "string") return module.js; - if (module && typeof module.cjs === "string") return module.cjs; - return null; -}; - -export default { - async fetch() { - const result = await createWorker({ - files: { - "tool.ts": "export default { answer: 42 };", - "__executor_entry.ts": "import artifact from './tool.ts';\\nexport default artifact;\\n", - }, - entryPoint: "__executor_entry.ts", - bundle: true, - target: "es2022", - minify: false, - jsx: "automatic", - conditions: ["workerd", "worker", "browser", "import", "default"], - virtualModules: {}, - }); - const code = moduleCode(result.modules[result.mainModule]); - return Response.json({ ok: typeof code === "string", code }); - }, -}; -`; - -describe("dynamic Worker worker-bundler spike", () => { - it("loads esbuild.wasm as a wasm module and bundles JavaScript", async () => { - const loader = (env as { readonly LOADER: WorkerLoader }).LOADER; - const worker = loader.load({ - compatibilityDate: "2025-06-01", - compatibilityFlags: ["nodejs_compat"], - mainModule: "driver.js", - modules: { - "driver.js": driver, - "worker-bundler.js": __WORKER_BUNDLER_SOURCE__, - "esbuild.wasm": { wasm: base64ToArrayBuffer(__WORKER_BUNDLER_WASM_BASE64__) }, - }, - }); - - const response = await worker.getEntrypoint().fetch("https://worker.test/run"); - const body = (await response.json()) as { readonly ok?: unknown; readonly code?: unknown }; - - expect(body.ok).toBe(true); - expect(body.code).toEqual(expect.stringContaining("answer")); - }); -}); - -describe("dynamic Worker bundler backend", () => { - const loader = getOnly((env as { readonly LOADER: WorkerLoader }).LOADER); - - it.effect("bundles a tool importing zod through the Worker Loader backend", () => - Effect.gen(function* () { - const bundled = yield* bundleEntry( - { - entry: "tools/greet.ts", - files: files([ - [ - "tools/greet.ts", - ` - import { z } from "zod"; - import { defineTool } from "executor:app"; - - const Input = z.object({ name: z.string() }); - - export default defineTool({ - description: "Greet", - input: Input, - async handler(input) { - return { greeting: "hello " + input.name }; - }, - }); - `, - ], - ["package.json", JSON.stringify({ dependencies: { zod: "4.3.6" } })], - ["bun.lock", ""], - ]), - }, - makeDynamicWorkerBundlerBackend({ loader, artifact }), - ); - - expect(bundled.code).toContain("Greet"); - expect(bundled.code).toContain("hello "); - }), - ); - - it.effect("surfaces install failures as bundle AppExecutorError failures", () => - Effect.gen(function* () { - const error = yield* bundleEntry( - { - entry: "tools/greet.ts", - files: files([ - [ - "tools/greet.ts", - ` - import missing from "executor-worker-bundler-missing-package"; - import { defineTool } from "executor:app"; - - export default defineTool({ - description: "Greet", - async handler() { - return { greeting: String(missing) }; - }, - }); - `, - ], - [ - "package.json", - JSON.stringify({ - dependencies: { "executor-worker-bundler-missing-package": "999.999.999" }, - }), - ], - ["bun.lock", ""], - ]), - }, - makeDynamicWorkerBundlerBackend({ loader, artifact }), - ).pipe(Effect.flip); - - expect(error).toBeInstanceOf(AppExecutorError); - expect(error.kind).toBe("bundle"); - expect(error.message).toMatch(/failed to install|No matching version|notarget/i); - }), - ); -}); diff --git a/packages/plugins/apps/src/pipeline/esbuild-bundler.ts b/packages/plugins/apps/src/pipeline/esbuild-bundler.ts deleted file mode 100644 index 80a05660bf..0000000000 --- a/packages/plugins/apps/src/pipeline/esbuild-bundler.ts +++ /dev/null @@ -1,172 +0,0 @@ -// In-process esbuild bundle backend. esbuild's Node launcher touches -// process.versions at module scope, so this file must stay out of the neutral -// module graph (cloud Workers): publish() loads it lazily only when a host did -// not inject a bundler. Import it directly only from Node-side code and tests. -import { build, version as esbuildVersion } from "esbuild"; -import { Effect, Option, Schema } from "effect"; - -import { AppExecutorError } from "../executor/app-tool-executor"; -import type { ToolchainRef } from "./descriptor"; -import type { BundleBackend } from "./bundle"; - -const BUNDLE_TARGET = "es2022"; -const FILESET_NS = "executor-apps-fileset"; -const VIRTUAL_NS = "executor-apps-virtual"; -const VIRTUAL_ENTRY = "executor-apps:entry"; -const VIRTUAL_APP = "executor:app"; - -export const toolchainRef = (): ToolchainRef => ({ - bundler: { name: "esbuild", version: esbuildVersion }, - executor: { name: "in-process-data-url", version: "0.1.0" }, - target: BUNDLE_TARGET, -}); - -const EsbuildDiagnostic = Schema.Struct({ text: Schema.String }); -const EsbuildFailure = Schema.Struct({ errors: Schema.Array(EsbuildDiagnostic) }); -const decodeEsbuildFailure = Schema.decodeUnknownOption(EsbuildFailure); - -const bundleFailureMessage = (entry: string, cause: unknown): string => { - const decoded = Option.getOrNull(decodeEsbuildFailure(cause)); - const detail = decoded?.errors.map((error) => error.text).join("; "); - return detail ? `bundle failed for ${entry}: ${detail}` : `bundle failed for ${entry}`; -}; - -const resolveRelative = (base: string, rel: string): string => { - const parts = (base ? base.split("/") : []).concat(rel.split("/")); - const out: string[] = []; - for (const part of parts) { - if (part === "" || part === ".") continue; - if (part === "..") out.pop(); - else out.push(part); - } - return out.join("/"); -}; - -const candidates = (path: string): readonly string[] => - /\.(tsx?|jsx?)$/.test(path) - ? [path] - : [ - path, - `${path}.ts`, - `${path}.tsx`, - `${path}.js`, - `${path}.jsx`, - `${path}/index.ts`, - `${path}/index.tsx`, - ]; - -const virtualEntrySource = (authorEntry: string): string => ` -import artifact from ${JSON.stringify(`/${authorEntry}`)}; -export default artifact; -`; - -const executorAppSource = ` -const makeIntegrationDeclaration = (state) => Object.freeze({ - kind: "integration", - slug: state.slug, - mode: state.mode, - ...(state.description !== undefined ? { description: state.description } : {}), - array: () => makeIntegrationDeclaration({ ...state, mode: "many" }), - describe: (text) => makeIntegrationDeclaration({ ...state, description: text }), -}); -export const integration = (slug) => - makeIntegrationDeclaration({ slug, mode: "one" }); -export const defineTool = (definition) => ({ ...definition, "~executorAppTool": true }); -`; - -const isAllowedBare = (path: string): boolean => path === "zod" || path.startsWith("zod/"); - -const fileSetPlugin = (files: ReadonlyMap, authorEntry: string) => ({ - name: "executor-apps-fileset", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setup(build2: any) { - const norm = (path: string) => path.replace(/^\.\//, "").replace(/\/\.\//g, "/"); - build2.onResolve( - { filter: /.*/ }, - (args: { path: string; importer: string; namespace?: string }) => { - if (args.path === VIRTUAL_ENTRY) return { path: VIRTUAL_ENTRY, namespace: VIRTUAL_NS }; - if (args.path === VIRTUAL_APP) return { path: VIRTUAL_APP, namespace: VIRTUAL_NS }; - const fromFileSet = args.namespace === FILESET_NS || args.namespace === VIRTUAL_NS; - if (!fromFileSet) return undefined; - if (isAllowedBare(args.path)) return undefined; - if (args.path.startsWith("/")) { - const joined = norm(args.path.slice(1)); - for (const candidate of candidates(joined)) { - if (files.has(candidate)) return { path: candidate, namespace: FILESET_NS }; - } - return { errors: [{ text: `cannot resolve "${args.path}" in the app file set` }] }; - } - if (args.path.startsWith(".")) { - const base = args.importer.split("/").slice(0, -1).join("/"); - const joined = norm(resolveRelative(base, args.path)); - for (const candidate of candidates(joined)) { - if (files.has(candidate)) return { path: candidate, namespace: FILESET_NS }; - } - return { - errors: [ - { text: `cannot resolve "${args.path}" from "${args.importer}" in the app file set` }, - ], - }; - } - return { errors: [{ text: `bare import "${args.path}" is not allowed` }] }; - }, - ); - build2.onLoad({ filter: /.*/, namespace: VIRTUAL_NS }, (args: { path: string }) => ({ - contents: args.path === VIRTUAL_ENTRY ? virtualEntrySource(authorEntry) : executorAppSource, - loader: "ts", - resolveDir: process.cwd(), - })); - build2.onLoad({ filter: /.*/, namespace: FILESET_NS }, (args: { path: string }) => { - const contents = files.get(args.path); - if (contents === undefined) - return { errors: [{ text: `missing file in set: ${args.path}` }] }; - const loader = args.path.endsWith(".tsx") - ? "tsx" - : args.path.endsWith(".ts") - ? "ts" - : args.path.endsWith(".jsx") - ? "jsx" - : "js"; - return { contents, loader, resolveDir: process.cwd() }; - }); - }, -}); - -export const inProcessBundleBackend = (): BundleBackend => ({ - toolchain: toolchainRef, - bundle: (input) => - Effect.tryPromise({ - try: () => - build({ - entryPoints: [VIRTUAL_ENTRY], - bundle: true, - write: false, - format: "esm", - platform: "neutral", - target: BUNDLE_TARGET, - minify: false, - treeShaking: true, - jsx: "automatic", - logLevel: "silent", - plugins: [fileSetPlugin(input.files, input.entry) as never], - }), - catch: (cause) => - new AppExecutorError({ - kind: "bundle", - message: bundleFailureMessage(input.entry, cause), - cause, - }), - }).pipe( - Effect.flatMap((result) => { - const out = result.outputFiles?.[0]?.text; - return out === undefined - ? Effect.fail( - new AppExecutorError({ - kind: "bundle", - message: `bundle failed for ${input.entry}: esbuild produced no output`, - }), - ) - : Effect.succeed({ code: out, toolchain: toolchainRef() }); - }), - ), -}); diff --git a/packages/plugins/apps/src/pipeline/publish.test.ts b/packages/plugins/apps/src/pipeline/publish.test.ts deleted file mode 100644 index 1eaa1c4b5d..0000000000 --- a/packages/plugins/apps/src/pipeline/publish.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Result } from "effect"; - -import { AppPublishConflictError, type AppsStore } from "../plugin/store"; -import { makeInProcessAppToolExecutor } from "../executor/app-tool-executor"; -import type { AppDescriptor } from "./descriptor"; -import { discover } from "./discover"; -import { publish } from "./publish"; - -const encoder = new TextEncoder(); - -const file = (path: string, body: string) => ({ path, bytes: encoder.encode(body) }); - -const tool = (description = "Tool") => ` - import { z } from "zod"; - import { defineTool } from "executor:app"; - export default defineTool({ - description: ${JSON.stringify(description)}, - input: z.object({ value: z.string() }), - handler(input) { return input; }, - }); -`; - -const makeMemoryStore = (): AppsStore & { - readonly rows: Map; - readonly blobs: Map; -} => { - const blobs = new Map(); - const rows = new Map(); - let descriptor: AppDescriptor | null = null; - let descriptorKey: string | null = null; - return { - rows, - blobs, - putBlob: (body) => - Effect.sync(() => { - const key = `blob:${body.length}:${blobs.size}`; - blobs.set(key, body); - return key; - }), - getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null), - getDescriptorRecord: () => - Effect.sync(() => - descriptor ? { sourceRef: descriptor.sourceRef, descriptorKey: descriptorKey ?? "" } : null, - ), - putPublished: (next, nextDescriptorKey, _owner, expectedSourceRef) => - Effect.gen(function* () { - const actualSourceRef = descriptor?.sourceRef ?? null; - if (actualSourceRef !== expectedSourceRef) { - return yield* new AppPublishConflictError({ - app: next.app, - expectedSourceRef, - actualSourceRef, - }); - } - descriptor = next; - descriptorKey = nextDescriptorKey; - for (const key of rows.keys()) rows.delete(key); - for (const item of next.tools) rows.set(item.name, item); - }), - removePublished: (app) => - Effect.sync(() => { - if (descriptor?.app === app) { - descriptor = null; - descriptorKey = null; - } - for (const key of rows.keys()) rows.delete(key); - }), - listActiveTools: () => - Effect.sync( - () => descriptor?.tools.map((tool) => ({ ...tool, app: descriptor?.app ?? "" })) ?? [], - ), - getTool: (name) => - Effect.sync(() => { - const row = descriptor?.tools.find((item) => item.name === name); - return row - ? { - app: descriptor!.app, - name: row.name, - bundleKey: row.bundleKey, - description: row.description, - inputSchema: row.inputSchema, - outputSchema: row.outputSchema, - integrations: row.integrations, - annotations: row.annotations, - } - : null; - }), - getToolForApp: (app, name) => - Effect.sync(() => { - const row = - descriptor?.app === app ? descriptor.tools.find((item) => item.name === name) : undefined; - return row - ? { - app: descriptor!.app, - name: row.name, - bundleKey: row.bundleKey, - description: row.description, - inputSchema: row.inputSchema, - outputSchema: row.outputSchema, - integrations: row.integrations, - annotations: row.annotations, - } - : null; - }), - putSource: () => Effect.void, - listSources: () => Effect.succeed([]), - getSource: () => Effect.succeed(null), - removeSource: () => Effect.void, - }; -}; - -const makeBarrierStore = () => { - const store = makeMemoryStore(); - let reads = 0; - let release: (() => void) | null = null; - const barrier = new Promise((resolve) => { - release = resolve; - }); - return { - ...store, - getDescriptorRecord: () => - Effect.promise(async () => { - reads += 1; - if (reads === 2) release?.(); - if (reads <= 2) await barrier; - return null; - }), - } satisfies AppsStore & { - readonly rows: Map; - readonly blobs: Map; - }; -}; - -describe("discover", () => { - it("reports non-tool files under tools and reserved folders", () => { - const result = discover( - new Map([ - ["tools/readme.md", ""], - ["workflows/a.ts", ""], - ["ui/panel.tsx", ""], - ["skills/guide.md", ""], - ]), - ); - expect(result).toMatchObject({ - _tag: "PublishError", - diagnostics: [{ path: "tools/readme.md" }], - }); - }); -}); - -describe("publish", () => { - it.effect("publishes tools from an in-memory file set", () => - Effect.gen(function* () { - const store = makeMemoryStore(); - const result = yield* publish( - { store, executor: makeInProcessAppToolExecutor(), now: () => 10 }, - { - app: "crm", - sourceRef: "sha-1", - files: [file("tools/sync.ts", tool()), file("workflows/later.ts", "")], - }, - ); - expect(result.publishedTools).toEqual(["sync"]); - expect(result.skipped).toEqual([{ path: "workflows/later.ts", reason: "not supported yet" }]); - expect(result.descriptor.toolchain.executor.name).toBe("in-process-data-url"); - expect(store.blobs.size).toBe(2); - }), - ); - - it.effect("treats identical sourceRef as a no-op", () => - Effect.gen(function* () { - const store = makeMemoryStore(); - const input = { - app: "crm", - sourceRef: "sha-1", - files: [file("tools/sync.ts", tool())], - }; - yield* publish({ store, executor: makeInProcessAppToolExecutor() }, input); - const second = yield* publish({ store, executor: makeInProcessAppToolExecutor() }, input); - expect(second.noop).toBe(true); - expect(second.publishedTools).toEqual([]); - }), - ); - - it.effect("rejects nondeterministic factories", () => - Effect.gen(function* () { - const store = makeMemoryStore(); - yield* publish( - { store, executor: makeInProcessAppToolExecutor() }, - { - app: "crm", - sourceRef: "sha-1", - files: [ - file( - "tools/sync.ts", - ` - import { z } from "zod"; - import { defineTool } from "executor:app"; - export default () => ({ - [String(Math.random()).slice(2)]: defineTool({ - description: "Random", - input: z.object({}), - handler() { return {}; }, - }), - }); - `, - ), - ], - }, - ).pipe(Effect.flip); - expect(store.rows.size).toBe(0); - }), - ); - - it.effect("leaves no rows or blobs when one tool fails collect", () => - Effect.gen(function* () { - const store = makeMemoryStore(); - yield* publish( - { store, executor: makeInProcessAppToolExecutor() }, - { - app: "crm", - sourceRef: "sha-1", - files: [ - file("tools/ok.ts", tool("OK")), - file( - "tools/bad.ts", - ` - import { z } from "zod"; - import { defineTool, integration } from "executor:app"; - export default defineTool({ - description: "Bad", - integrations: { crm: { kind: "integration", slug: "dealcloud", mode: "bad" } }, - input: z.object({}), - handler(input) { return input; }, - }); - `, - ), - ], - }, - ).pipe(Effect.flip); - expect(store.rows.size).toBe(0); - expect(store.blobs.size).toBe(0); - }), - ); - - it.effect("guards concurrent publishes for the same app", () => - Effect.gen(function* () { - const store = makeBarrierStore(); - const results = yield* Effect.all( - [ - publish( - { store, executor: makeInProcessAppToolExecutor(), now: () => 1 }, - { app: "crm", sourceRef: "sha-a", files: [file("tools/a.ts", tool("A"))] }, - ).pipe(Effect.result), - publish( - { store, executor: makeInProcessAppToolExecutor(), now: () => 2 }, - { app: "crm", sourceRef: "sha-b", files: [file("tools/b.ts", tool("B"))] }, - ).pipe(Effect.result), - ], - { concurrency: "unbounded" }, - ); - const successes = results.filter(Result.isSuccess); - const failures = results.filter(Result.isFailure); - expect(successes).toHaveLength(1); - expect(failures).toHaveLength(1); - expect([...store.rows.keys()]).toHaveLength(1); - }), - ); -}); diff --git a/packages/plugins/apps/src/pipeline/publish.ts b/packages/plugins/apps/src/pipeline/publish.ts deleted file mode 100644 index ed45f0792d..0000000000 --- a/packages/plugins/apps/src/pipeline/publish.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { Effect, Predicate, Schema } from "effect"; -import { sha256Hex, type Owner } from "@executor-js/sdk"; - -import type { AppToolExecutor, CollectedTool } from "../executor/app-tool-executor"; -import type { AppsStore } from "../plugin/store"; -import { bundleEntry, defaultBundleBackend, type BundleBackend } from "./bundle"; -import { - DESCRIPTOR_VERSION, - stableStringify, - type AppDescriptor, - type ModuleSourceRef, - type ToolDescriptor, -} from "./descriptor"; -import { discover, PublishError, type SkippedArtifact } from "./discover"; -export { PublishError } from "./discover"; - -export const PUBLISH_LIMITS = { - maxFiles: 256, - maxFileBytes: 1024 * 1024, - maxTotalBytes: 4 * 1024 * 1024, -} as const; - -export interface PublishFile { - readonly path: string; - readonly bytes: Uint8Array; -} - -export interface PublishInput { - readonly app: string; - readonly owner?: Owner; - readonly files: readonly PublishFile[]; - readonly sourceRef: string; -} - -export interface PublishResult { - readonly descriptor: AppDescriptor; - readonly publishedTools: readonly string[]; - readonly skipped: readonly SkippedArtifact[]; - readonly noop: boolean; -} - -export interface PublishDeps { - readonly store: AppsStore; - readonly executor: AppToolExecutor; - readonly bundler?: BundleBackend; - readonly now?: () => number; -} - -const textDecoder = new TextDecoder(); -const textEncoder = new TextEncoder(); - -const byteLength = (value: Uint8Array | string): number => - typeof value === "string" ? textEncoder.encode(value).byteLength : value.byteLength; - -export const enforcePublishLimits = (files: readonly PublishFile[]): PublishError | null => { - const diagnostics: { readonly path: string; readonly message: string }[] = []; - if (files.length > PUBLISH_LIMITS.maxFiles) { - diagnostics.push({ - path: "", - message: `publish has ${files.length} files, exceeding the limit of ${PUBLISH_LIMITS.maxFiles}`, - }); - } - let total = 0; - for (const file of files) { - const size = byteLength(file.bytes); - total += size; - if (size > PUBLISH_LIMITS.maxFileBytes) { - diagnostics.push({ - path: file.path, - message: `file is ${size} bytes, exceeding the per-file limit of ${PUBLISH_LIMITS.maxFileBytes} bytes`, - }); - } - } - if (total > PUBLISH_LIMITS.maxTotalBytes) { - diagnostics.push({ - path: "", - message: `publish total is ${total} bytes, exceeding the total limit of ${PUBLISH_LIMITS.maxTotalBytes} bytes`, - }); - } - return diagnostics.length === 0 - ? null - : new PublishError({ - stage: "discover", - message: `publish payload exceeds limits (${diagnostics.length} problem(s))`, - diagnostics, - }); -}; - -const fileMap = (files: readonly PublishFile[]): ReadonlyMap => { - const out = new Map(); - for (const file of files) out.set(file.path, textDecoder.decode(file.bytes)); - return out; -}; - -const sourceRef = (path: string, source: string): Effect.Effect => - sha256Hex(source).pipe(Effect.map((sourceHash) => ({ path, sourceHash }))); - -const isPublishError = Predicate.isTagged("PublishError") as ( - value: unknown, -) => value is PublishError; - -const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); - -const toPublishError = ( - stage: PublishError["stage"], - path: string, - cause: { - readonly message: string; - readonly diagnostics?: readonly { readonly path: string; readonly message: string }[]; - }, -): PublishError => - new PublishError({ - stage, - // oxlint-disable-next-line executor/no-unknown-error-message -- typed AppExecutorError conversion keeps its stable diagnostic message - message: cause.message, - diagnostics: - cause.diagnostics && cause.diagnostics.length > 0 - ? cause.diagnostics - : // oxlint-disable-next-line executor/no-unknown-error-message -- typed AppExecutorError conversion keeps its stable diagnostic message - [{ path, message: cause.message }], - }); - -const toolDescriptor = (input: { - readonly collected: CollectedTool; - readonly sourcePath: string; - readonly bundleKey: string; - readonly source: ModuleSourceRef; -}): ToolDescriptor => ({ - name: input.collected.toolName, - sourcePath: input.sourcePath, - bundleKey: input.bundleKey, - source: input.source, - description: input.collected.description, - integrations: input.collected.integrations, - inputSchema: input.collected.inputSchema, - outputSchema: input.collected.outputSchema, - annotations: input.collected.annotations, -}); - -export const publish = ( - deps: PublishDeps, - input: PublishInput, -): Effect.Effect => - Effect.gen(function* () { - const owner = input.owner ?? "org"; - const bundler = - deps.bundler ?? - (yield* defaultBundleBackend().pipe( - Effect.mapError((error) => toPublishError("bundle", "", error)), - )); - const limitError = enforcePublishLimits(input.files); - if (limitError) return yield* limitError; - const files = fileMap(input.files); - const discovered = discover(files); - if (isPublishError(discovered)) return yield* discovered; - - const existing = yield* deps.store.getDescriptorRecord(input.app).pipe( - Effect.mapError( - (cause) => - new PublishError({ - stage: "project", - message: "failed to read existing app descriptor", - diagnostics: [], - cause, - } as never), - ), - ); - if (existing?.sourceRef === input.sourceRef) { - const descriptorBody = yield* deps.store.getBlob(existing.descriptorKey).pipe( - Effect.mapError( - () => - new PublishError({ - stage: "project", - message: "failed to read existing descriptor blob", - diagnostics: [], - }), - ), - ); - if (descriptorBody) { - return { - descriptor: decodeJson(descriptorBody) as AppDescriptor, - publishedTools: [], - skipped: discovered.skipped, - noop: true, - }; - } - } - - const staged: { - readonly entry: string; - readonly bundle: string; - readonly collected: readonly CollectedTool[]; - readonly source: ModuleSourceRef; - }[] = []; - for (const artifact of discovered.tools) { - const bundled = yield* bundleEntry({ files, entry: artifact.entry }, bundler).pipe( - Effect.mapError((cause) => toPublishError("bundle", artifact.entry, cause)), - ); - const collected = yield* deps.executor - .collect(bundled.code, { fileSlug: artifact.name, sourcePath: artifact.entry }) - .pipe(Effect.mapError((cause) => toPublishError("collect", artifact.entry, cause))); - const source = yield* sourceRef(artifact.entry, files.get(artifact.entry) ?? ""); - staged.push({ - entry: artifact.entry, - bundle: bundled.code, - collected: collected.tools, - source, - }); - } - - const tools: ToolDescriptor[] = []; - for (const artifact of staged) { - const bundleKey = yield* deps.store.putBlob(artifact.bundle, owner).pipe( - Effect.mapError( - () => - new PublishError({ - stage: "project", - message: `failed to write bundle for ${artifact.entry}`, - diagnostics: [{ path: artifact.entry, message: "bundle blob write failed" }], - }), - ), - ); - for (const tool of artifact.collected) { - tools.push( - toolDescriptor({ - collected: tool, - sourcePath: artifact.entry, - bundleKey, - source: artifact.source, - }), - ); - } - } - - const descriptor: AppDescriptor = { - version: DESCRIPTOR_VERSION, - app: input.app, - sourceRef: input.sourceRef, - publishedAt: deps.now?.() ?? Date.now(), - toolchain: bundler.toolchain(), - tools, - workflows: discovered.skipped.filter((item) => item.path.startsWith("workflows/")), - ui: discovered.skipped.filter((item) => item.path.startsWith("ui/")), - skills: discovered.skipped.filter((item) => item.path.startsWith("skills/")), - skipped: discovered.skipped, - }; - const descriptorBody = stableStringify(descriptor); - const descriptorKey = yield* deps.store.putBlob(descriptorBody, owner).pipe( - Effect.mapError( - () => - new PublishError({ - stage: "project", - message: "failed to write descriptor blob", - diagnostics: [], - }), - ), - ); - yield* deps.store - .putPublished(descriptor, descriptorKey, owner, existing?.sourceRef ?? null) - .pipe( - Effect.mapError((cause) => { - if (Predicate.isTagged("AppPublishConflictError")(cause)) { - return new PublishError({ - stage: "project", - message: `app "${input.app}" changed during publish`, - diagnostics: [{ path: "", message: "publish sourceRef changed during publish" }], - }); - } - return new PublishError({ - stage: "project", - message: "failed to persist app publication", - diagnostics: [], - }); - }), - ); - return { - descriptor, - publishedTools: descriptor.tools.map((tool) => tool.name), - skipped: discovered.skipped, - noop: false, - }; - }); diff --git a/packages/plugins/apps/src/pipeline/worker-bundler-artifact.ts b/packages/plugins/apps/src/pipeline/worker-bundler-artifact.ts deleted file mode 100644 index 6118ad8ddf..0000000000 --- a/packages/plugins/apps/src/pipeline/worker-bundler-artifact.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Node-side build helper reports missing worker-bundler artifacts */ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -// Resolved through the module graph rather than a path relative to this file: -// this module runs both from src/ (bun) and bundled into dist/ chunks (node -// loading the built vite plugin), where directory-counting breaks. The package -// only declares an `import` export condition, so resolve with -// import.meta.resolve (ESM conditions) and take the entry's parent dir. -const workerBundlerDistDir = (): string => { - const override = process.env.EXECUTOR_WORKER_BUNDLER_DIR; - if (override) return join(override, "dist"); - return dirname(fileURLToPath(import.meta.resolve("@cloudflare/worker-bundler"))); -}; - -export const bundledWorkerBundler = async (): Promise<{ - readonly source: string; - readonly wasm: Uint8Array; -}> => { - const distDir = workerBundlerDistDir(); - const bundledEntry = join(distDir, "index.bundled.js"); - const [sourceFromDisk, wasm] = await Promise.all([ - existsSync(bundledEntry) ? readFile(bundledEntry, "utf8") : Promise.resolve(null), - readFile(join(distDir, "esbuild.wasm")), - ]); - if (sourceFromDisk !== null) return { source: sourceFromDisk, wasm }; - - const { build } = await import("esbuild"); - const result = await build({ - entryPoints: [join(distDir, "index.js")], - bundle: true, - format: "esm", - platform: "browser", - write: false, - external: ["./esbuild.wasm"], - logLevel: "silent", - }); - const source = result.outputFiles[0]?.text; - if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); - return { source, wasm }; -}; diff --git a/packages/plugins/apps/src/pipeline/worker-bundler-version.ts b/packages/plugins/apps/src/pipeline/worker-bundler-version.ts deleted file mode 100644 index ca939c763e..0000000000 --- a/packages/plugins/apps/src/pipeline/worker-bundler-version.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const WORKER_BUNDLER_VERSION = "0.2.1"; -export const WORKER_BUNDLER_ESBUILD_VERSION = "0.28.0"; diff --git a/packages/plugins/apps/src/pipeline/worker-bundler.test.ts b/packages/plugins/apps/src/pipeline/worker-bundler.test.ts deleted file mode 100644 index b0649765d5..0000000000 --- a/packages/plugins/apps/src/pipeline/worker-bundler.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-unknown-error-message -- test boundary: network availability probe and typed error assertions */ -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; - -import { makeWorkerdAppToolExecutor } from "../executor/workerd-app-tool-executor"; -import { isWorkerdAvailable } from "@executor-js/runtime-workerd-subprocess"; -import { bundleEntry } from "./bundle"; -import { makeWorkerBundlerBackend } from "./worker-bundler"; -import { WORKER_BUNDLER_VERSION } from "./worker-bundler-version"; - -const registryReachable = async (): Promise => { - try { - const response = await fetch("https://registry.npmjs.org/zod", { method: "HEAD" }); - return response.ok; - } catch { - return false; - } -}; - -const files = (entries: readonly (readonly [string, string])[]): ReadonlyMap => - new Map(entries); - -const available = isWorkerdAvailable() && (await registryReachable()); - -describe("worker-bundler backend", () => { - if (!available) { - it.skip("skipped because workerd or registry.npmjs.org is unavailable", () => {}); - return; - } - - it.effect("bundles a tool importing zod and runs it under workerd", () => - Effect.gen(function* () { - const bundled = yield* bundleEntry( - { - entry: "tools/greet.ts", - files: files([ - [ - "tools/greet.ts", - ` - import { z } from "zod"; - import { defineTool } from "executor:app"; - - const Input = z.object({ name: z.string() }); - - export default defineTool({ - description: "Greet", - input: Input, - async handler(input) { - return { greeting: "hello " + input.name }; - }, - }); - `, - ], - ["package.json", JSON.stringify({ dependencies: { zod: "4.3.6" } })], - ["bun.lock", ""], - ]), - }, - makeWorkerBundlerBackend(), - ); - const executor = makeWorkerdAppToolExecutor(); - const collected = yield* executor.collect(bundled.code, { - fileSlug: "greet", - sourcePath: "tools/greet.ts", - }); - expect(collected.tools.map((tool) => tool.toolName)).toEqual(["greet"]); - expect(collected.tools[0]?.inputSchema).toMatchObject({ - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - }); - const invoked = yield* executor.invoke( - bundled.code, - { toolName: "greet" }, - { name: "Ada" }, - { call: async () => null }, - { timeoutMs: 1000 }, - ); - expect(invoked.output).toEqual({ greeting: "hello Ada" }); - expect(makeWorkerBundlerBackend().toolchain().bundler.version).toContain( - WORKER_BUNDLER_VERSION, - ); - }), - ); - - it.effect("rejects package lifecycle scripts before installing dependencies", () => - Effect.gen(function* () { - const error = yield* bundleEntry( - { - entry: "tools/greet.ts", - files: files([ - [ - "tools/greet.ts", - ` - import { defineTool } from "executor:app"; - export default defineTool({ - description: "Greet", - async handler() { return { ok: true }; }, - }); - `, - ], - [ - "package.json", - JSON.stringify({ - name: "blocked-app", - scripts: { postinstall: "node build.js" }, - dependencies: { zod: "4.3.6" }, - }), - ], - ["bun.lock", ""], - ]), - }, - makeWorkerBundlerBackend(), - ).pipe(Effect.flip); - expect(error.message).toContain("blocked-app"); - expect(error.message).toContain("postinstall"); - }), - ); - - it.effect("rejects non-registry dependency specs before installing dependencies", () => - Effect.gen(function* () { - const error = yield* bundleEntry( - { - entry: "tools/greet.ts", - files: files([ - [ - "tools/greet.ts", - ` - import { defineTool } from "executor:app"; - export default defineTool({ - description: "Greet", - async handler() { return { ok: true }; }, - }); - `, - ], - [ - "package.json", - JSON.stringify({ - name: "blocked-app", - dependencies: { external: "https://example.com/external.tgz" }, - }), - ], - ["bun.lock", ""], - ]), - }, - makeWorkerBundlerBackend(), - ).pipe(Effect.flip); - expect(error.message).toContain("external"); - expect(error.message).toContain("unsupported non-registry spec"); - }), - ); - - it.effect("rejects native addon artifacts before bundling", () => - Effect.gen(function* () { - const error = yield* bundleEntry( - { - entry: "tools/greet.ts", - files: files([ - [ - "tools/greet.ts", - ` - import { defineTool } from "executor:app"; - export default defineTool({ - description: "Greet", - async handler() { return { ok: true }; }, - }); - `, - ], - ["package.json", JSON.stringify({ name: "native-app" })], - ["node_modules/native/binding.gyp", "{}"], - ["bun.lock", ""], - ]), - }, - makeWorkerBundlerBackend(), - ).pipe(Effect.flip); - expect(error.message).toContain("unsupported native module artifact"); - }), - ); -}); diff --git a/packages/plugins/apps/src/pipeline/worker-bundler.ts b/packages/plugins/apps/src/pipeline/worker-bundler.ts deleted file mode 100644 index c51fa448df..0000000000 --- a/packages/plugins/apps/src/pipeline/worker-bundler.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-unknown-error-message, executor/no-error-constructor, executor/no-json-parse, executor/no-instanceof-tagged-error, executor/no-instanceof-error -- boundary: subprocess-backed bundler validates package JSON and converts worker failures into typed AppExecutorError */ -import { - createWorkerdModuleRunner, - WORKERD_VERSION, -} from "@executor-js/runtime-workerd-subprocess"; -import { Effect } from "effect"; - -import { AppExecutorError } from "../executor/app-tool-executor"; -import type { BundleBackend, BundleOutput } from "./bundle"; -import { - driverModule, - enforceOutputLimit, - fileRecord, - packageBoundaryError, - registryOriginsFromEnv, -} from "./bundler-driver"; -import type { ToolchainRef } from "./descriptor"; -import { bundledWorkerBundler } from "./worker-bundler-artifact"; -import { WORKER_BUNDLER_ESBUILD_VERSION, WORKER_BUNDLER_VERSION } from "./worker-bundler-version"; - -const workerBundlerModule = bundledWorkerBundler(); - -const toolchain = (): ToolchainRef => ({ - bundler: { - name: "@cloudflare/worker-bundler", - version: `${WORKER_BUNDLER_VERSION} (esbuild-wasm ${WORKER_BUNDLER_ESBUILD_VERSION})`, - }, - executor: { name: "workerd-subprocess", version: WORKERD_VERSION }, - target: "es2022", -}); - -export const makeWorkerBundlerBackend = (): BundleBackend => ({ - toolchain, - bundle: (input): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const boundaryError = packageBoundaryError(input); - if (boundaryError) throw boundaryError; - const modules = await workerBundlerModule; - const token = crypto.randomUUID(); - const runner = createWorkerdModuleRunner({ - mainModule: "driver.js", - modules: { - "driver.js": driverModule({ token, registryOrigins: registryOriginsFromEnv() }), - "worker-bundler.js": modules.source, - "esbuild.wasm": { kind: "wasm", bytes: modules.wasm }, - }, - hostToken: token, - globalOutbound: "internet", - restartBackoffMs: 1, - }); - try { - const response = await runner.run<{ - readonly ok: boolean; - readonly code?: string; - readonly message?: string; - }>({ files: fileRecord(input.files), entry: input.entry }); - if (!response.body.ok || typeof response.body.code !== "string") { - throw new AppExecutorError({ - kind: "bundle", - message: response.body.message ?? `worker-bundler failed for ${input.entry}`, - diagnostics: [ - { path: input.entry, message: response.body.message ?? "worker-bundler failed" }, - ], - }); - } - const limitError = enforceOutputLimit(input.entry, response.body.code); - if (limitError) throw limitError; - return { code: response.body.code, toolchain: toolchain() }; - } finally { - await runner.dispose(); - } - }, - catch: (cause) => - cause instanceof AppExecutorError - ? cause - : new AppExecutorError({ - kind: "bundle", - message: `worker-bundler failed for ${input.entry}: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - cause, - }), - }), -}); diff --git a/packages/plugins/apps/src/plugin/apps-plugin.test.ts b/packages/plugins/apps/src/plugin/apps-plugin.test.ts deleted file mode 100644 index dee5bea1ec..0000000000 --- a/packages/plugins/apps/src/plugin/apps-plugin.test.ts +++ /dev/null @@ -1,813 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw -- boundary: test temporarily overrides global fetch and restores it in finally */ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; -import { IntegrationSlug, ToolResult, createExecutor, definePlugin } from "@executor-js/sdk"; -import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; - -import { FLUSH, pktLine } from "../git-client/pktline"; -import { bundleEntry } from "../pipeline/bundle"; -import { makeInProcessAppToolExecutor, type AppToolExecutor } from "../executor/app-tool-executor"; -import { DRIVER_VERSION } from "../executor/dynamic-worker-app-tool-executor"; -import type { AppDescriptor } from "../pipeline/descriptor"; -import { makeAppsPlugin, projectAppsToolSchema } from "./apps-plugin"; -import type { AppSourceRecord, AppsStore } from "./store"; - -const bundle = (source: string) => - bundleEntry({ - files: new Map([["tools/sync.ts", source]]), - entry: "tools/sync.ts", - }); - -const localAppDir = (toolName: string, message: string) => - Effect.acquireRelease( - Effect.promise(async () => { - const root = await mkdtemp(join(tmpdir(), "executor-apps-test-")); - await mkdir(join(root, "tools")); - await writeFile( - join(root, "tools", `${toolName}.ts`), - ` - import { z } from "zod"; - import { defineTool } from "executor:app"; - export default defineTool({ - name: "${toolName}", - description: "${message}", - input: z.object({}), - async handler() { - return { message: "${message}" }; - }, - }); - `, - ); - return root; - }), - (root) => Effect.promise(() => rm(root, { recursive: true, force: true })), - ); - -const collidingPlugin = definePlugin(() => ({ - id: "collider", - storage: () => ({}), - extension: (ctx) => ({ - seed: () => - ctx.core.integrations.register({ - slug: IntegrationSlug.make("github"), - name: "GitHub", - description: "Existing integration", - config: {}, - }), - }), -}))(); - -const concat = (parts: readonly Uint8Array[]): Uint8Array => { - const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); - let offset = 0; - for (const part of parts) { - out.set(part, offset); - offset += part.length; - } - return out; -}; - -const advertisement = (sha: string): Uint8Array => - concat([ - pktLine("# service=git-upload-pack\n"), - FLUSH, - pktLine( - `${sha} HEAD\0symref=HEAD:refs/heads/main multi_ack side-band-64k thin-pack ofs-delta\n`, - ), - pktLine(`${sha} refs/heads/main\n`), - FLUSH, - ]); - -const sideBand = (bytes: Uint8Array): Uint8Array => { - const payload = new Uint8Array(bytes.length + 1); - payload[0] = 1; - payload.set(bytes, 1); - return concat([pktLine("NAK\n"), pktLine(payload), FLUSH]); -}; - -const makeSyncStore = (): AppsStore & { - readonly sources: Map; - readonly descriptor: (app: string) => AppDescriptor | null; -} => { - const blobs = new Map(); - const sources = new Map(); - const descriptors = new Map(); - return { - sources, - descriptor: (app) => descriptors.get(app)?.descriptor ?? null, - putBlob: (body) => - Effect.sync(() => { - const key = `blob:${blobs.size}`; - blobs.set(key, body); - return key; - }), - getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null), - getDescriptorRecord: (app) => - Effect.sync(() => { - const current = descriptors.get(app); - return current - ? { - sourceRef: current.descriptor.sourceRef, - descriptorKey: current.descriptorKey, - } - : null; - }), - putPublished: (next, nextDescriptorKey) => - Effect.sync(() => { - descriptors.set(next.app, { descriptor: next, descriptorKey: nextDescriptorKey }); - }), - removePublished: (app) => - Effect.sync(() => { - descriptors.delete(app); - }), - listActiveTools: () => - Effect.sync(() => - [...descriptors.values()].flatMap(({ descriptor }) => - descriptor.tools.map((tool) => ({ ...tool, app: descriptor.app })), - ), - ), - getTool: (name) => - Effect.sync(() => { - const descriptor = [...descriptors.values()].find((entry) => - entry.descriptor.tools.some((item) => item.name === name), - )?.descriptor; - const tool = descriptor?.tools.find((item) => item.name === name); - if (!descriptor || !tool) return null; - return { - app: descriptor.app, - name: tool.name, - bundleKey: tool.bundleKey, - description: tool.description, - inputSchema: tool.inputSchema, - outputSchema: tool.outputSchema, - integrations: tool.integrations, - annotations: tool.annotations, - }; - }), - getToolForApp: (app, name) => - Effect.sync(() => { - const descriptor = descriptors.get(app)?.descriptor; - const tool = descriptor?.tools.find((item) => item.name === name); - if (!descriptor || !tool) return null; - return { - app: descriptor.app, - name: tool.name, - bundleKey: tool.bundleKey, - description: tool.description, - inputSchema: tool.inputSchema, - outputSchema: tool.outputSchema, - integrations: tool.integrations, - annotations: tool.annotations, - }; - }), - putSource: (record) => - Effect.sync(() => { - sources.set(record.slug, record); - }), - listSources: () => Effect.sync(() => [...sources.values()]), - getSource: (slug) => Effect.sync(() => sources.get(slug) ?? null), - removeSource: (slug) => - Effect.sync(() => { - sources.delete(slug); - }), - }; -}; - -const fixtureFetch = async () => { - const dir = join(import.meta.dirname, "..", "source", "fixtures"); - const [shas, pack1, pack2] = await Promise.all([ - readFile(join(dir, "git-fixture-shas.txt"), "utf8"), - readFile(join(dir, "git-fixture-v1.pack")), - readFile(join(dir, "git-fixture-v2.pack")), - ]); - const [sha1, sha2] = shas.trim().split("\n"); - let current = { sha: sha1!, pack: new Uint8Array(pack1) }; - let packRequests = 0; - return { - fetch: (async (rawUrl: string) => { - const url = new URL(rawUrl); - if (url.pathname === "/repo.git/info/refs") { - return new Response(new Uint8Array(advertisement(current.sha)).buffer, { - headers: { "content-type": "application/x-git-upload-pack-advertisement" }, - }); - } - if (url.pathname === "/repo.git/git-upload-pack") { - packRequests += 1; - return new Response(new Uint8Array(sideBand(current.pack)).buffer, { - headers: { "content-type": "application/x-git-upload-pack-result" }, - }); - } - return new Response("not found", { status: 404 }); - }) as typeof fetch, - advance: () => { - current = { sha: sha2!, pack: new Uint8Array(pack2) }; - }, - packRequests: () => packRequests, - }; -}; - -const makeInvokeStore = (input: { - readonly bundle: string; - readonly bundleKey?: string; - readonly integrations: AppDescriptor["tools"][number]["integrations"]; -}): AppsStore => ({ - putBlob: () => Effect.succeed("bundle"), - getBlob: () => Effect.succeed(input.bundle), - getDescriptorRecord: () => Effect.succeed(null), - putPublished: () => Effect.void, - removePublished: () => Effect.void, - listActiveTools: () => Effect.succeed([]), - getTool: () => - Effect.succeed({ - app: "crm", - name: "sync", - bundleKey: input.bundleKey ?? "bundle", - description: "Sync", - integrations: input.integrations, - }), - getToolForApp: () => - Effect.succeed({ - app: "crm", - name: "sync", - bundleKey: input.bundleKey ?? "bundle", - description: "Sync", - integrations: input.integrations, - }), - putSource: () => Effect.void, - listSources: () => Effect.succeed([]), - getSource: () => Effect.succeed(null), - removeSource: () => Effect.void, -}); - -const invokeCtx = (input: { - readonly bundle: string; - readonly bundleKey?: string; - readonly execute: (address: string, args: unknown) => Effect.Effect; -}) => - ({ - owner: { tenant: "tenant-a", subject: null }, - storage: makeInvokeStore({ - bundle: input.bundle, - ...(input.bundleKey ? { bundleKey: input.bundleKey } : {}), - integrations: { crm: { slug: "dealcloud", mode: "one" } }, - }), - connections: { - list: () => Effect.succeed([]), - get: () => - Effect.succeed({ - address: "tools.dealcloud.org.main", - integration: "dealcloud", - owner: "org", - name: "main", - }), - }, - execute: (address: string, args: unknown) => input.execute(String(address), args), - }) as never; - -describe("apps plugin schema projection", () => { - it.effect("narrows synthesized integration fields to connection address enums", () => - Effect.gen(function* () { - const storage: AppsStore = { - putBlob: () => Effect.succeed("bundle"), - getBlob: () => Effect.succeed(null), - getDescriptorRecord: () => Effect.succeed(null), - putPublished: () => Effect.void, - removePublished: () => Effect.void, - listActiveTools: () => Effect.succeed([]), - putSource: () => Effect.void, - listSources: () => Effect.succeed([]), - getSource: () => Effect.succeed(null), - removeSource: () => Effect.void, - getTool: () => - Effect.succeed({ - app: "crm", - name: "sync", - bundleKey: "bundle", - description: "Sync", - integrations: { - crm: { slug: "dealcloud", mode: "one" }, - inboxes: { slug: "gmail", mode: "many" }, - }, - }), - getToolForApp: () => - Effect.succeed({ - app: "crm", - name: "sync", - bundleKey: "bundle", - description: "Sync", - integrations: { - crm: { slug: "dealcloud", mode: "one" }, - inboxes: { slug: "gmail", mode: "many" }, - }, - }), - }; - const result = yield* projectAppsToolSchema( - { - storage, - connections: { - list: ({ integration }: { readonly integration?: unknown }) => - Effect.succeed( - String(integration) === "dealcloud" - ? [{ address: "tools.dealcloud.org.main" }] - : [{ address: "tools.gmail.org.work" }, { address: "tools.gmail.user.personal" }], - ), - }, - } as never, - "crm", - "sync", - { - type: "object", - properties: { - updatedSince: { type: "string" }, - crm: { type: "string" }, - inboxes: { type: "array", items: { type: "string" } }, - }, - required: ["crm", "inboxes"], - }, - undefined, - ); - expect(result.inputSchema).toMatchObject({ - properties: { - crm: { - enum: ["tools.dealcloud.org.main"], - default: "tools.dealcloud.org.main", - }, - inboxes: { - default: ["tools.gmail.org.work", "tools.gmail.user.personal"], - items: { - enum: ["tools.gmail.org.work", "tools.gmail.user.personal"], - }, - }, - }, - }); - // The response schema requires JSON values: a `required` key holding - // undefined fails encoding (the /api/tools/schema 400), so the key must - // be absent entirely when all required fields were projected away. - expect("required" in (result.inputSchema as Record)).toBe(false); - expect(Object.values(result.inputSchema as Record).includes(undefined)).toBe( - false, - ); - }), - ); -}); - -describe("apps source sync", () => { - it.effect("rejects credential-bearing git URLs before storing a source", () => - Effect.gen(function* () { - const store = makeSyncStore(); - const plugin = makeAppsPlugin({ allowPrivateGitHosts: true }); - const extension = plugin.extension!({ - storage: store, - providers: { - setDefault: () => Effect.succeed("default"), - get: () => Effect.succeed(null), - remove: () => Effect.void, - }, - } as never); - const exit = yield* Effect.exit( - extension.createSource({ - kind: "git", - slug: "bad", - app: "bad", - url: "https://x:secret@example.test/repo", - }), - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(store.sources.size).toBe(0); - }), - ); - - it.effect("redacts query strings from persisted source diagnostics", () => - Effect.gen(function* () { - const store = makeSyncStore(); - store.sources.set("bad", { - slug: "bad", - app: "bad", - kind: "git", - config: { kind: "git", url: "https://example.test/repo?token=secret" }, - status: { type: "pending" }, - updatedAt: 1, - }); - const plugin = makeAppsPlugin({ - executor: makeInProcessAppToolExecutor(), - allowPrivateGitHosts: true, - }); - const extension = plugin.extension!({ - storage: store, - providers: { - setDefault: () => Effect.succeed("default"), - get: () => Effect.succeed(null), - remove: () => Effect.void, - }, - } as never); - const result = yield* extension.syncSource("bad"); - expect(result.status).toBe("failed"); - const status = store.sources.get("bad")?.status; - const diagnostic = status?.type === "failed" ? status.errors[0]?.diagnostics?.[0]?.path : ""; - expect(diagnostic).toBe("https://example.test/repo"); - }), - ); - - it.effect("rejects directory listing when local-directory sources are disabled", () => - Effect.gen(function* () { - const plugin = makeAppsPlugin({ sourceKinds: ["git"] }); - const extension = plugin.extension!({ - storage: makeSyncStore(), - } as never); - const exit = yield* Effect.exit(extension.listDirs({ path: "/tmp" })); - expect(Exit.isFailure(exit)).toBe(true); - }), - ); - - it.effect("syncs git sources, no-ops unchanged refs, and republishes changed refs", () => - Effect.gen(function* () { - const fixture = yield* Effect.promise(() => fixtureFetch()); - const originalFetch = globalThis.fetch; - globalThis.fetch = fixture.fetch; - try { - const store = makeSyncStore(); - const plugin = makeAppsPlugin({ - executor: makeInProcessAppToolExecutor(), - allowPrivateGitHosts: true, - }); - const integrations = new Set(); - const connections = new Set(); - const extension = plugin.extension!({ - storage: store, - providers: { - setDefault: () => Effect.succeed("default"), - get: () => Effect.succeed(null), - remove: () => Effect.void, - }, - core: { - integrations: { - get: (slug: unknown) => - Effect.sync(() => - integrations.has(String(slug)) - ? ({ slug, kind: "apps", description: "", canRemove: true } as never) - : null, - ), - register: (input: { readonly slug: unknown }) => - Effect.sync(() => { - integrations.add(String(input.slug)); - }), - remove: (slug: unknown) => - Effect.sync(() => { - integrations.delete(String(slug)); - connections.delete(String(slug)); - }), - }, - }, - connections: { - list: () => Effect.succeed([]), - resolveValue: () => Effect.succeed(null), - get: ({ integration }: { readonly integration: unknown }) => - Effect.succeed(connections.has(String(integration)) ? ({} as never) : null), - create: ({ integration }: { readonly integration: unknown }) => - Effect.sync(() => { - connections.add(String(integration)); - }), - refresh: () => Effect.succeed([]), - }, - } as never); - - yield* extension.createSource({ - kind: "git", - slug: "fixture", - app: "fixture", - url: "https://example.test/repo", - }); - const first = yield* extension.syncSource("fixture"); - expect(first.status).toBe("published"); - expect(first.tools).toEqual(["greeter"]); - expect(fixture.packRequests()).toBe(1); - - const unchanged = yield* extension.syncSource("fixture"); - expect(unchanged.status).toBe("up-to-date"); - expect(fixture.packRequests()).toBe(1); - - fixture.advance(); - const changed = yield* extension.syncSource("fixture"); - expect(changed.status).toBe("published"); - expect(changed.sourceRef).not.toBe(first.sourceRef); - expect(fixture.packRequests()).toBe(2); - expect(store.descriptor("fixture")?.sourceRef).toBe(changed.sourceRef); - } finally { - globalThis.fetch = originalFetch; - } - }), - ); - - it.effect("registers each app as its own integration with disjoint tool addresses", () => - Effect.scoped( - Effect.gen(function* () { - const alphaDir = yield* localAppDir("echo", "alpha"); - const betaDir = yield* localAppDir("echo", "beta"); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [ - memoryCredentialsPlugin(), - makeAppsPlugin({ sourceKinds: ["local-directory"] }), - ] as const, - }), - ); - - yield* executor.apps.createSource({ - kind: "local-directory", - slug: "alpha-source", - app: "alpha-tools", - path: alphaDir, - }); - yield* executor.apps.createSource({ - kind: "local-directory", - slug: "beta-source", - app: "beta-tools", - path: betaDir, - }); - - const alphaSync = yield* executor.apps.syncSource("alpha-source"); - const betaSync = yield* executor.apps.syncSource("beta-source"); - expect(alphaSync.status).toBe("published"); - expect(betaSync.status).toBe("published"); - - const alphaIntegration = yield* executor.integrations.get( - IntegrationSlug.make("alpha-tools"), - ); - const betaIntegration = yield* executor.integrations.get( - IntegrationSlug.make("beta-tools"), - ); - expect(alphaIntegration?.name).toBe("alpha-tools"); - expect(betaIntegration?.name).toBe("beta-tools"); - - const alphaTools = yield* executor.tools.list({ - integration: IntegrationSlug.make("alpha-tools"), - }); - const betaTools = yield* executor.tools.list({ - integration: IntegrationSlug.make("beta-tools"), - }); - expect(alphaTools.map((tool) => String(tool.address))).toEqual([ - "tools.alpha-tools.org.published.echo", - ]); - expect(betaTools.map((tool) => String(tool.address))).toEqual([ - "tools.beta-tools.org.published.echo", - ]); - }), - ), - ); - - it.effect("rejects an app slug that collides with a non-app integration before publishing", () => - Effect.scoped( - Effect.gen(function* () { - const sourceDir = yield* localAppDir("echo", "collision"); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [ - memoryCredentialsPlugin(), - collidingPlugin, - makeAppsPlugin({ sourceKinds: ["local-directory"] }), - ] as const, - }), - ); - yield* executor.collider.seed(); - yield* executor.apps.createSource({ - kind: "local-directory", - slug: "github-source", - app: "github", - path: sourceDir, - }); - - const result = yield* executor.apps.syncSource("github-source"); - expect(result.status).toBe("failed"); - expect(result.errors?.[0]?.message).toContain("github"); - const tools = yield* executor.tools.list({ integration: IntegrationSlug.make("github") }); - expect(tools).toEqual([]); - }), - ), - ); - - it.effect("keeps the app source panel reachable after a source-stage sync failure", () => - Effect.scoped( - Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [ - memoryCredentialsPlugin(), - makeAppsPlugin({ sourceKinds: ["local-directory"] }), - ] as const, - }), - ); - yield* executor.apps.createSource({ - kind: "local-directory", - slug: "missing-source", - app: "missing-tools", - path: "/definitely/missing/executor-app-source", - }); - - const result = yield* executor.apps.syncSource("missing-source"); - expect(result.status).toBe("failed"); - expect(result.errors?.[0]?.stage).toBe("source"); - const integration = yield* executor.integrations.get(IntegrationSlug.make("missing-tools")); - expect(integration?.kind).toBe("apps"); - expect( - yield* executor.tools.list({ integration: IntegrationSlug.make("missing-tools") }), - ).toEqual([]); - }), - ), - ); - - it.effect("rejects two app sources with the same app slug", () => - Effect.scoped( - Effect.gen(function* () { - const firstDir = yield* localAppDir("first", "first"); - const secondDir = yield* localAppDir("second", "second"); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [ - memoryCredentialsPlugin(), - makeAppsPlugin({ sourceKinds: ["local-directory"] }), - ] as const, - }), - ); - yield* executor.apps.createSource({ - kind: "local-directory", - slug: "first-source", - app: "shared-app", - path: firstDir, - }); - yield* executor.apps.createSource({ - kind: "local-directory", - slug: "second-source", - app: "shared-app", - path: secondDir, - }); - - const result = yield* executor.apps.syncSource("first-source"); - expect(result.status).toBe("failed"); - expect(result.errors?.[0]?.message).toContain("shared-app"); - const tools = yield* executor.tools.list({ - integration: IntegrationSlug.make("shared-app"), - }); - expect(tools).toEqual([]); - }), - ), - ); - - it.effect("deleteSource removes the app integration and republish recreates it", () => - Effect.scoped( - Effect.gen(function* () { - const sourceDir = yield* localAppDir("echo", "delete"); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [ - memoryCredentialsPlugin(), - makeAppsPlugin({ sourceKinds: ["local-directory"] }), - ] as const, - }), - ); - yield* executor.apps.createSource({ - kind: "local-directory", - slug: "delete-source", - app: "delete-tools", - path: sourceDir, - }); - const firstSync = yield* executor.apps.syncSource("delete-source"); - expect(firstSync.status).toBe("published"); - expect( - (yield* executor.tools.list({ integration: IntegrationSlug.make("delete-tools") })).map( - (tool) => String(tool.address), - ), - ).toEqual(["tools.delete-tools.org.published.echo"]); - - yield* executor.apps.deleteSource("delete-source"); - expect(yield* executor.integrations.get(IntegrationSlug.make("delete-tools"))).toBe(null); - expect( - yield* executor.tools.list({ integration: IntegrationSlug.make("delete-tools") }), - ).toEqual([]); - - yield* executor.apps.createSource({ - kind: "local-directory", - slug: "delete-source", - app: "delete-tools", - path: sourceDir, - }); - const secondSync = yield* executor.apps.syncSource("delete-source"); - expect(secondSync.status).toBe("published"); - expect( - (yield* executor.tools.list({ integration: IntegrationSlug.make("delete-tools") })).map( - (tool) => String(tool.address), - ), - ).toEqual(["tools.delete-tools.org.published.echo"]); - }), - ), - ); -}); - -describe("apps plugin invocation", () => { - it.effect("passes a tenant-scoped stable isolate key to the app tool executor", () => - Effect.gen(function* () { - let capturedIsolateKey: string | undefined; - const executor: AppToolExecutor = { - collect: () => Effect.succeed({ tools: [] }), - invoke: (_bundle, _entry, _input, _bridge, limits) => { - capturedIsolateKey = limits.isolateKey; - return Effect.succeed({ output: { ok: true } }); - }, - }; - const plugin = makeAppsPlugin({ executor }); - const result = yield* plugin.invokeTool!({ - ctx: invokeCtx({ - bundle: "export default {};", - bundleKey: "apps/test-bundle", - execute: () => Effect.succeed({}), - }), - toolRow: { integration: "crm", name: "sync" }, - args: { crm: "tools.dealcloud.org.main" }, - } as never); - - expect(result).toEqual({ ok: true }); - expect(capturedIsolateKey).toBe(`tenant-a:apps/test-bundle:${DRIVER_VERSION}`); - }), - ); - - it.effect("surfaces uncaught inner tool failures without binding_error", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool, integration } from "executor:app"; - export default defineTool({ - description: "Sync", - integrations: { crm: integration("dealcloud") }, - input: z.object({}), - async handler(_input, { crm }) { - await crm.deals.list({}); - return { ok: true }; - }, - }); - `); - const plugin = makeAppsPlugin({ executor: makeInProcessAppToolExecutor() }); - const result = yield* plugin.invokeTool!({ - ctx: invokeCtx({ - bundle: bundled.code, - execute: () => - Effect.succeed( - ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }), - ), - }), - toolRow: { integration: "crm", name: "sync" }, - args: { crm: "tools.dealcloud.org.main" }, - } as never); - expect(result).toMatchObject({ - ok: false, - error: { - code: "upstream_failed", - message: expect.stringContaining("tools.dealcloud.org.main.deals.list"), - }, - }); - expect(result).toMatchObject({ - error: { message: expect.stringContaining('"CRM unavailable"') }, - }); - expect((result as { readonly error: { readonly code: string } }).error.code).not.toBe( - "binding_error", - ); - }), - ); - - it.effect("lets handlers catch inner tool failures and return a fallback", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool, integration } from "executor:app"; - export default defineTool({ - description: "Sync", - integrations: { crm: integration("dealcloud") }, - input: z.object({}), - async handler(_input, { crm }) { - try { - await crm.deals.list({}); - return { fallback: false }; - } catch { - return { fallback: true }; - } - }, - }); - `); - const plugin = makeAppsPlugin({ executor: makeInProcessAppToolExecutor() }); - const result = yield* plugin.invokeTool!({ - ctx: invokeCtx({ - bundle: bundled.code, - execute: () => - Effect.succeed( - ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }), - ), - }), - toolRow: { integration: "crm", name: "sync" }, - args: { crm: "tools.dealcloud.org.main" }, - } as never); - expect(result).toEqual({ fallback: true }); - }), - ); -}); diff --git a/packages/plugins/apps/src/plugin/apps-plugin.ts b/packages/plugins/apps/src/plugin/apps-plugin.ts deleted file mode 100644 index 5b83abdd1d..0000000000 --- a/packages/plugins/apps/src/plugin/apps-plugin.ts +++ /dev/null @@ -1,709 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw -- boundary: plugin source config validation is converted into the extension Effect failure channel */ -import { Data, Effect, Predicate, Result } from "effect"; -import { - AuthTemplateSlug, - ConnectionName, - IntegrationSlug, - ProviderItemId, - ProviderKey, - ToolName, - ToolResult, - definePlugin, - IntegrationAlreadyExistsError, - type PluginCtx, - type ToolDef, -} from "@executor-js/sdk"; - -import { makeInProcessAppToolExecutor, type AppToolExecutor } from "../executor/app-tool-executor"; -import type { BundleBackend } from "../pipeline/bundle"; -import { PUBLISH_LIMITS, publish } from "../pipeline/publish"; -import { buildBridge, resolveIntegrationBindings } from "./bindings"; -import { makePluginCtxAppsResolver } from "./resolver"; -import { - descriptorCollection, - makeAppsStore, - sourceCollection, - toolCollection, - type AppSourceConfig, - type AppSourceRecord, - type AppsStore, -} from "./store"; -import { - publishErrorToDiagnostic, - sourceErrorToDiagnostic, - type SyncDiagnostic, -} from "../source/app-source"; -import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "../source/git-source"; -import { - fetchLocalDirectoryAppSource, - listLocalDirectoryDirs, -} from "../source/local-directory-source"; -import { AppSourceError, type AppSourceSnapshot } from "../source/app-source"; -import type { PublishError } from "../pipeline/publish"; -import { DRIVER_VERSION } from "../executor/dynamic-worker-app-tool-executor"; - -const APPS_CONNECTION = ConnectionName.make("published"); -const APPS_NO_AUTH = AuthTemplateSlug.make("none"); - -class AppPluginError extends Data.TaggedError("AppPluginError")<{ - readonly message: string; -}> {} - -class AppSlugConflictError extends Data.TaggedError("AppSlugConflictError")<{ - readonly app: string; -}> {} - -interface ProjectedToolSchema { - readonly inputSchema?: unknown; - readonly outputSchema?: unknown; -} - -const innerToolError = ( - cause: unknown, -): { readonly address: string; readonly innerMessage: string; readonly code?: string } | null => { - const direct = - Predicate.isTagged("AppInnerToolError")(cause) && typeof cause === "object" && cause !== null - ? (cause as { - readonly address?: unknown; - readonly innerMessage?: unknown; - readonly code?: unknown; - }) - : null; - const nested = - direct === null && - cause !== null && - typeof cause === "object" && - "cause" in cause && - Predicate.isTagged("AppInnerToolError")(cause.cause) - ? (cause.cause as { - readonly address?: unknown; - readonly innerMessage?: unknown; - readonly code?: unknown; - }) - : direct; - if ( - nested === null || - typeof nested.address !== "string" || - typeof nested.innerMessage !== "string" - ) { - return null; - } - return { - address: nested.address, - innerMessage: nested.innerMessage, - ...(typeof nested.code === "string" ? { code: nested.code } : {}), - }; -}; - -const slugify = (value: string): string => - value - .trim() - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80); - -export interface CreateAppSourceInput { - readonly slug?: string; - readonly app?: string; - readonly kind: AppSourceConfig["kind"]; - readonly url?: string; - readonly ref?: string; - readonly token?: string; - readonly path?: string; -} - -export type SyncAppSourceResult = - | { - readonly status: "published"; - readonly sourceRef: string; - readonly tools: readonly string[]; - readonly errors?: undefined; - } - | { - readonly status: "up-to-date"; - readonly sourceRef: string; - readonly tools: readonly string[]; - readonly errors?: undefined; - } - | { - readonly status: "failed"; - readonly sourceRef?: string; - readonly tools: readonly string[]; - readonly errors: readonly SyncDiagnostic[]; - }; - -const sourceConfig = (input: CreateAppSourceInput): AppSourceConfig => { - if (input.kind === "git") { - if (!input.url) throw new AppPluginError({ message: "git source url is required" }); - return { - kind: "git", - url: input.url, - ...(input.ref ? { ref: input.ref } : {}), - }; - } - if (!input.path) throw new AppPluginError({ message: "local-directory source path is required" }); - return { kind: "local-directory", path: input.path }; -}; - -const appDescription = (record: AppSourceRecord, description?: string): string => { - if (description) return description; - if (record.description) return record.description; - if (record.config.kind === "git") return `Custom tools from ${record.config.url}`; - return `Custom tools from ${record.config.path}`; -}; - -const ensureAppIntegration = ( - ctx: PluginCtx, - record: AppSourceRecord, - description?: string, -): Effect.Effect => - Effect.gen(function* () { - const appSlug = IntegrationSlug.make(record.app); - const existing = yield* ctx.core.integrations.get(appSlug); - if (existing && existing.kind !== "apps") return; - yield* ctx.core.integrations.register({ - slug: appSlug, - name: record.app, - description: appDescription(record, description), - config: {}, - }); - }); - -const ensureAppConnection = ( - ctx: PluginCtx, - record: AppSourceRecord, - description?: string, -): Effect.Effect => - Effect.gen(function* () { - const appSlug = IntegrationSlug.make(record.app); - yield* ensureAppIntegration(ctx, record, description); - const existing = yield* ctx.connections.get({ - owner: "org", - integration: appSlug, - name: APPS_CONNECTION, - }); - if (existing) { - yield* ctx.connections.refresh({ - owner: "org", - integration: appSlug, - name: APPS_CONNECTION, - }); - return; - } - yield* ctx.connections.create({ - owner: "org", - integration: appSlug, - name: APPS_CONNECTION, - template: APPS_NO_AUTH, - values: {}, - }); - }); - -const guardAppIntegration = ( - ctx: PluginCtx, - record: AppSourceRecord, -): Effect.Effect => - Effect.gen(function* () { - const appSlug = IntegrationSlug.make(record.app); - const existing = yield* ctx.core.integrations.get(appSlug); - if (existing && existing.kind !== "apps") { - return yield* new IntegrationAlreadyExistsError({ slug: appSlug }); - } - const sources = yield* ctx.storage.listSources(); - const duplicate = sources.find( - (source) => source.slug !== record.slug && source.app === record.app, - ); - if (duplicate) { - return yield* new AppSlugConflictError({ app: record.app }); - } - }); - -const appRegistrationErrorToDiagnostic = (error: unknown): SyncDiagnostic => { - if (Predicate.isTagged("IntegrationAlreadyExistsError")(error)) { - const slug = String((error as IntegrationAlreadyExistsError).slug); - return { - stage: "project", - message: `Integration slug collides with an existing integration: ${slug}`, - diagnostics: [{ path: slug, message: `Integration slug already exists: ${slug}` }], - }; - } - if (Predicate.isTagged("AppSlugConflictError")(error)) { - const app = (error as AppSlugConflictError).app; - const message = `app source already exists for slug: ${app}`; - return { - stage: "project", - message, - diagnostics: [{ path: app, message }], - }; - } - return { - stage: "project", - message: "App publish failed", - }; -}; - -const tokenItemId = (slug: string): ProviderItemId => - ProviderItemId.make(`apps/source-tokens/${slug}`); - -// A source authenticates only with its own explicitly provided token. No -// implicit credential sharing: a stored GitHub connection must never leak -// into git fetches the user did not tie to it. -const gitTokenFor = ( - ctx: PluginCtx, - config: Extract, -): Effect.Effect => - Effect.gen(function* () { - if (config.tokenProvider && config.tokenItemId) { - return yield* ctx.providers.get( - ProviderKey.make(config.tokenProvider), - ProviderItemId.make(config.tokenItemId), - ); - } - return null; - }); - -const activeToolNamesFor = ( - ctx: PluginCtx, - app: string, -): Effect.Effect => - ctx.storage - .listActiveTools() - .pipe(Effect.map((tools) => tools.filter((tool) => tool.app === app).map((tool) => tool.name))); - -const makeAppsExtension = ( - ctx: PluginCtx, - options?: Pick< - AppsPluginOptions, - "executor" | "bundler" | "sourceKinds" | "allowPrivateGitHosts" - >, -) => { - const executor = options?.executor; - const activeExecutor = executor ?? makeInProcessAppToolExecutor(); - const activeBundler = options?.bundler; - const sourceKinds = options?.sourceKinds ?? ["git", "local-directory"]; - const now = () => Date.now(); - return { - publish: (input: Parameters[1]) => - publish({ store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, input), - listSources: () => ctx.storage.listSources(), - getSource: (slug: string) => ctx.storage.getSource(slug), - listDirs: (input: { readonly path?: string; readonly includeHidden?: boolean }) => - Effect.gen(function* () { - if (!sourceKinds.includes("local-directory")) { - return yield* new AppPluginError({ - message: "app source kind is not enabled: local-directory", - }); - } - return yield* listLocalDirectoryDirs(input); - }), - createSource: (input: CreateAppSourceInput) => - Effect.gen(function* () { - const config = sourceConfig(input); - if (!sourceKinds.includes(config.kind)) { - return yield* new AppPluginError({ - message: `app source kind is not enabled: ${config.kind}`, - }); - } - if (config.kind === "git") { - yield* parseGitSourceUrl(config.url, { - allowPrivateHosts: options?.allowPrivateGitHosts === true, - }).pipe( - Effect.mapError( - () => - new AppPluginError({ - message: "git source URL is not valid", - }), - ), - ); - } - const slug = slugify( - input.slug ?? input.app ?? (config.kind === "git" ? config.url : config.path), - ); - if (!slug) return yield* new AppPluginError({ message: "source slug is required" }); - const app = slugify(input.app ?? slug); - const storedConfig = - config.kind === "git" && input.token - ? { - ...config, - tokenProvider: String( - yield* ctx.providers.setDefault(tokenItemId(slug), input.token), - ), - tokenItemId: String(tokenItemId(slug)), - } - : config; - const record: AppSourceRecord = { - slug, - app, - kind: storedConfig.kind, - config: storedConfig, - status: { type: "pending" }, - updatedAt: now(), - }; - yield* ctx.storage.putSource(record, "org"); - yield* ensureAppIntegration(ctx, record); - return record; - }), - deleteSource: (slug: string) => - Effect.gen(function* () { - const record = yield* ctx.storage.getSource(slug); - if (record) { - yield* ctx.storage.removePublished(record.app, "org"); - yield* ctx.core.integrations - .remove(IntegrationSlug.make(record.app)) - .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void)); - } - if ( - record?.config.kind === "git" && - record.config.tokenProvider && - record.config.tokenItemId - ) { - yield* ctx.providers.remove( - ProviderKey.make(record.config.tokenProvider), - ProviderItemId.make(record.config.tokenItemId), - ); - } - yield* ctx.storage.removeSource(slug, "org"); - return { removed: true }; - }), - syncSource: (slug: string): Effect.Effect => - Effect.gen(function* () { - const record = yield* ctx.storage.getSource(slug); - if (!record) return yield* new AppPluginError({ message: `app source not found: ${slug}` }); - if (!sourceKinds.includes(record.config.kind)) { - return yield* new AppPluginError({ - message: `app source kind is not enabled: ${record.config.kind}`, - }); - } - const gitToken = - record.config.kind === "git" ? yield* gitTokenFor(ctx, record.config) : null; - if (record.config.kind === "git") { - const checked = yield* checkGitAppSourceRefs({ - url: record.config.url, - ...(record.config.ref ? { ref: record.config.ref } : {}), - ...(gitToken ? { token: gitToken } : {}), - allowPrivateHosts: options?.allowPrivateGitHosts === true, - }).pipe(Effect.result); - if (Result.isFailure(checked)) { - const diagnostic = sourceErrorToDiagnostic(checked.failure); - const failed: AppSourceRecord = { - ...record, - status: { type: "failed", at: now(), errors: [diagnostic] }, - updatedAt: now(), - }; - yield* ctx.storage.putSource(failed, "org"); - return { status: "failed", tools: [], errors: [diagnostic] }; - } - if (record.sourceRef === checked.success.sourceRef) { - const guarded = yield* guardAppIntegration(ctx, record).pipe(Effect.result); - if (Result.isFailure(guarded)) { - const diagnostic = appRegistrationErrorToDiagnostic(guarded.failure); - const failed: AppSourceRecord = { - ...record, - status: { type: "failed", at: now(), errors: [diagnostic] }, - updatedAt: now(), - }; - yield* ctx.storage.putSource(failed, "org"); - return { status: "failed", tools: [], errors: [diagnostic] }; - } - const tools = yield* activeToolNamesFor(ctx, record.app); - const updated: AppSourceRecord = { - ...record, - status: { type: "up-to-date", at: now(), tools }, - updatedAt: now(), - }; - yield* ctx.storage.putSource(updated, "org"); - yield* ensureAppConnection(ctx, updated); - return { status: "up-to-date", sourceRef: checked.success.sourceRef, tools }; - } - } - let fetched: Result.Result; - if (record.config.kind === "git") { - fetched = yield* fetchGitAppSource({ - url: record.config.url, - ...(record.config.ref ? { ref: record.config.ref } : {}), - ...(gitToken ? { token: gitToken } : {}), - maxBytes: PUBLISH_LIMITS.maxTotalBytes, - allowPrivateHosts: options?.allowPrivateGitHosts === true, - }).pipe(Effect.result); - } else { - fetched = yield* fetchLocalDirectoryAppSource(record.config).pipe(Effect.result); - } - if (Result.isFailure(fetched)) { - const error = fetched.failure; - const diagnostic = Predicate.isTagged("PublishError")(error) - ? publishErrorToDiagnostic(error as PublishError) - : sourceErrorToDiagnostic(error as AppSourceError); - const failed: AppSourceRecord = { - ...record, - status: { type: "failed", at: now(), errors: [diagnostic] }, - updatedAt: now(), - }; - yield* ctx.storage.putSource(failed, "org"); - return { status: "failed", tools: [], errors: [diagnostic] }; - } - const snapshot: AppSourceSnapshot = fetched.success; - if (record.sourceRef === snapshot.sourceRef) { - const guarded = yield* guardAppIntegration(ctx, record).pipe(Effect.result); - if (Result.isFailure(guarded)) { - const diagnostic = appRegistrationErrorToDiagnostic(guarded.failure); - const failed: AppSourceRecord = { - ...record, - description: snapshot.description, - status: { type: "failed", at: now(), errors: [diagnostic] }, - updatedAt: now(), - }; - yield* ctx.storage.putSource(failed, "org"); - return { status: "failed", tools: [], errors: [diagnostic] }; - } - const tools = yield* activeToolNamesFor(ctx, record.app); - const updated: AppSourceRecord = { - ...record, - description: snapshot.description, - status: { type: "up-to-date", at: now(), tools }, - updatedAt: now(), - }; - yield* ctx.storage.putSource(updated, "org"); - yield* ensureAppConnection(ctx, updated, snapshot.description); - return { status: "up-to-date", sourceRef: snapshot.sourceRef, tools }; - } - const guarded = yield* guardAppIntegration(ctx, record).pipe(Effect.result); - if (Result.isFailure(guarded)) { - const diagnostic = appRegistrationErrorToDiagnostic(guarded.failure); - yield* ctx.storage.putSource( - { - ...record, - sourceRef: snapshot.sourceRef, - description: snapshot.description, - status: { type: "failed", at: now(), errors: [diagnostic] }, - updatedAt: now(), - }, - "org", - ); - return { - status: "failed", - sourceRef: snapshot.sourceRef, - tools: [], - errors: [diagnostic], - }; - } - const published = yield* publish( - { store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, - { - app: record.app, - files: snapshot.files, - sourceRef: snapshot.sourceRef, - }, - ).pipe(Effect.result); - if (Result.isFailure(published)) { - const diagnostic = publishErrorToDiagnostic(published.failure); - yield* ctx.storage.putSource( - { - ...record, - sourceRef: snapshot.sourceRef, - description: snapshot.description, - status: { type: "failed", at: now(), errors: [diagnostic] }, - updatedAt: now(), - }, - "org", - ); - return { - status: "failed", - sourceRef: snapshot.sourceRef, - tools: [], - errors: [diagnostic], - }; - } - yield* ctx.storage.putSource( - { - ...record, - sourceRef: snapshot.sourceRef, - description: snapshot.description, - status: { - type: published.success.noop ? "up-to-date" : "published", - at: now(), - tools: published.success.descriptor.tools.map((tool) => tool.name), - }, - updatedAt: now(), - }, - "org", - ); - yield* ensureAppConnection(ctx, record, snapshot.description); - return { - status: published.success.noop ? "up-to-date" : "published", - sourceRef: snapshot.sourceRef, - tools: published.success.descriptor.tools.map((tool) => tool.name), - }; - }), - }; -}; - -export type AppsExtension = ReturnType; - -export interface AppsPluginOptions { - readonly executor?: AppToolExecutor; - readonly bundler?: BundleBackend; - readonly sourceKinds?: readonly AppSourceConfig["kind"][]; - readonly allowPrivateGitHosts?: boolean; -} - -export const makeAppsPlugin = (options?: AppsPluginOptions) => - definePlugin(() => ({ - id: "apps", - packageName: "@executor-js/plugin-apps", - clientConfig: { sourceKinds: options?.sourceKinds ?? ["git", "local-directory"] }, - pluginStorage: { - [descriptorCollection.name]: descriptorCollection, - [toolCollection.name]: toolCollection, - [sourceCollection.name]: sourceCollection, - }, - storage: ({ blobs, pluginStorage }) => makeAppsStore({ blobs, pluginStorage }), - extension: (ctx: PluginCtx) => makeAppsExtension(ctx, options), - resolveTools: ({ storage, connection }) => - storage.listActiveTools().pipe( - Effect.map((tools) => ({ - tools: tools - .filter((tool) => tool.app === String(connection.integration)) - .map( - (tool): ToolDef => ({ - name: ToolName.make(tool.name), - description: tool.description, - inputSchema: tool.inputSchema, - outputSchema: tool.outputSchema, - annotations: { - ...(tool.annotations?.requiresApproval !== undefined - ? { requiresApproval: tool.annotations.requiresApproval } - : {}), - ...(tool.annotations?.readOnly === true ? { requiresApproval: false } : {}), - }, - }), - ), - })), - ), - projectToolSchema: ({ ctx, toolRow, inputSchema, outputSchema }) => - projectAppsToolSchema( - ctx, - String(toolRow.integration), - String(toolRow.name), - inputSchema, - outputSchema, - ), - validateToolArgs: ({ ctx, toolRow, args }) => - Effect.gen(function* () { - const tool = yield* ctx.storage.getToolForApp( - String(toolRow.integration), - String(toolRow.name), - ); - if (!tool) return; - const resolver = makePluginCtxAppsResolver({ ctx }); - yield* resolveIntegrationBindings(tool.integrations, args, resolver); - }), - invokeTool: ({ ctx, toolRow, args, invokeOptions }) => - Effect.gen(function* () { - const tool = yield* ctx.storage.getToolForApp( - String(toolRow.integration), - String(toolRow.name), - ); - if (!tool) { - return yield* new AppPluginError({ - message: `app tool not found: ${toolRow.integration}.${toolRow.name}`, - }); - } - const bundle = yield* ctx.storage.getBlob(tool.bundleKey); - if (!bundle) { - return yield* new AppPluginError({ message: `app tool bundle missing: ${tool.name}` }); - } - const resolver = makePluginCtxAppsResolver({ ctx }); - const bindings = yield* resolveIntegrationBindings(tool.integrations, args, resolver); - const bridge = buildBridge({ - declared: tool.integrations, - bindings: bindings.bindings, - resolver, - invokeOptions, - }); - // Tenant-scoped isolate key: bundleKey is content-addressed, so two - // orgs publishing byte-identical bundles would otherwise share a warm - // isolate and module-level state could cross tenants. - const result = yield* (options?.executor ?? makeInProcessAppToolExecutor()) - .invoke( - bundle, - { toolName: tool.name }, - { ...bindings.input, ...bindings.bindings }, - bridge, - { - timeoutMs: 30_000, - isolateKey: `${ctx.owner.tenant}:${tool.bundleKey}:${DRIVER_VERSION}`, - }, - ) - .pipe( - Effect.catch((cause: unknown) => { - const inner = innerToolError(cause); - return inner - ? Effect.succeed( - ToolResult.fail({ - code: inner.code ?? "inner_tool_error", - message: `Inner tool ${inner.address} failed: "${inner.innerMessage}"`, - }), - ) - : Effect.fail(cause); - }), - ); - return "output" in result ? result.output : result; - }), - }))(); - -export const projectAppsToolSchema = ( - ctx: PluginCtx, - app: string, - toolName: string, - inputSchema: unknown, - outputSchema: unknown, -): Effect.Effect => - Effect.gen(function* () { - const tool = yield* ctx.storage.getToolForApp(app, toolName); - if (!tool || !inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) { - return { inputSchema, outputSchema }; - } - const schema = inputSchema as Record; - const properties = - schema.properties && - typeof schema.properties === "object" && - !Array.isArray(schema.properties) - ? { ...(schema.properties as Record) } - : {}; - for (const [field, decl] of Object.entries(tool.integrations)) { - const connections = yield* ctx.connections.list({ integration: decl.slug as never }); - const enumValues = connections.map((connection) => String(connection.address)); - properties[field] = - decl.mode === "many" - ? { - type: "array", - items: { type: "string", enum: enumValues }, - default: enumValues, - } - : { - type: "string", - enum: enumValues, - ...(enumValues[0] ? { default: enumValues[0] } : {}), - }; - } - const projectedFields = new Set(Object.keys(tool.integrations)); - const required = Array.isArray(schema.required) - ? schema.required.filter((field) => !projectedFields.has(String(field))) - : undefined; - // Rebuild without the `required` key rather than setting it to undefined: - // an explicit undefined is not a JSON value and fails response encoding. - const { required: _dropped, ...rest } = schema; - return { - inputSchema: { - ...rest, - properties, - ...(required && required.length > 0 ? { required } : {}), - }, - outputSchema, - }; - }); - -export const appsPlugin = makeAppsPlugin(); - -export { APPS_CONNECTION }; diff --git a/packages/plugins/apps/src/plugin/bindings.test.ts b/packages/plugins/apps/src/plugin/bindings.test.ts deleted file mode 100644 index 8fce93bb0a..0000000000 --- a/packages/plugins/apps/src/plugin/bindings.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; - -import { buildBridge, resolveIntegrationBindings, type ClientResolver } from "./bindings"; - -const resolver: ClientResolver = { - listConnections: ({ integration }) => - Effect.succeed([{ integration, address: `tools.${integration}.org.main` }]), - resolveConnection: ({ connection }) => - Effect.succeed( - connection === "tools.dealcloud.org.main" - ? { integration: "dealcloud", address: connection } - : connection === "tools.github.org.main" - ? { integration: "github", address: connection } - : null, - ), - call: ({ path, args }) => Effect.succeed({ path, args }), -}; - -const one = (slug: string) => ({ slug, mode: "one" as const }); -const many = (slug: string) => ({ slug, mode: "many" as const }); - -describe("integration bindings", () => { - it.effect("resolves caller-supplied connection addresses", () => - Effect.gen(function* () { - const result = yield* resolveIntegrationBindings( - { crm: one("dealcloud") }, - { crm: "tools.dealcloud.org.main", updatedSince: "today" }, - resolver, - ); - expect(result).toEqual({ - input: { updatedSince: "today" }, - bindings: { crm: "tools.dealcloud.org.main" }, - }); - }), - ); - - it.effect("fails unknown connection addresses as BindingError", () => - Effect.gen(function* () { - const error = yield* resolveIntegrationBindings( - { crm: one("dealcloud") }, - { crm: "tools.dealcloud.org.missing" }, - resolver, - ).pipe(Effect.flip); - expect(error).toMatchObject({ _tag: "BindingError" }); - }), - ); - - it.effect("fails mismatched connection integrations as BindingError", () => - Effect.gen(function* () { - const error = yield* resolveIntegrationBindings( - { crm: one("dealcloud") }, - { crm: "tools.github.org.main" }, - resolver, - ).pipe(Effect.flip); - expect(error).toMatchObject({ message: expect.stringContaining('not "dealcloud"') }); - }), - ); - - it("bridges method calls to the resolver", async () => { - const bridge = buildBridge({ - declared: { crm: one("dealcloud") }, - bindings: { crm: "tools.dealcloud.org.main" }, - resolver, - }); - await expect(bridge.call("crm.deals.list", { limit: 1 })).resolves.toEqual({ - path: ["deals", "list"], - args: { limit: 1 }, - }); - }); - - it("rejects undeclared bridge calls", async () => { - const bridge = buildBridge({ - declared: { crm: one("dealcloud") }, - bindings: { crm: "tools.dealcloud.org.main" }, - resolver, - }); - await expect(bridge.call("github.repos.get", {})).rejects.toMatchObject({ - _tag: "BindingError", - }); - }); - - it("routes fan-out bridge calls to the indexed connection", async () => { - const calls: unknown[] = []; - const bridge = buildBridge({ - declared: { inboxes: many("gmail") }, - bindings: { inboxes: ["tools.gmail.org.work", "tools.gmail.user.personal"] }, - resolver: { - ...resolver, - call: (input) => - Effect.sync(() => { - calls.push(input); - return { ok: true }; - }), - }, - }); - await expect(bridge.call("inboxes#1.messages.list", { q: "unread" })).resolves.toEqual({ - ok: true, - }); - expect(calls).toMatchObject([ - { - integration: "gmail", - connection: "tools.gmail.user.personal", - path: ["messages", "list"], - args: { q: "unread" }, - }, - ]); - }); - - it.effect("resolves arrays of connection addresses for fan-out", () => - Effect.gen(function* () { - const result = yield* resolveIntegrationBindings( - { inboxes: many("gmail") }, - { inboxes: ["tools.gmail.org.work", "tools.gmail.user.personal"], q: "unread" }, - { - ...resolver, - resolveConnection: ({ connection }) => - Effect.succeed( - connection.startsWith("tools.gmail.") - ? { integration: "gmail", address: connection } - : null, - ), - }, - ); - expect(result).toEqual({ - input: { q: "unread" }, - bindings: { inboxes: ["tools.gmail.org.work", "tools.gmail.user.personal"] }, - }); - }), - ); -}); diff --git a/packages/plugins/apps/src/plugin/bindings.ts b/packages/plugins/apps/src/plugin/bindings.ts deleted file mode 100644 index 2826527f1f..0000000000 --- a/packages/plugins/apps/src/plugin/bindings.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { Data, Effect } from "effect"; -import type { InvokeOptions } from "@executor-js/sdk"; - -import type { AppToolBridge } from "../executor/app-tool-executor"; -import type { IntegrationDecl } from "../pipeline/descriptor"; - -export class BindingError extends Data.TaggedError("BindingError")<{ - readonly message: string; - readonly role: string; - readonly integration: string; - readonly requestedConnection?: string; -}> {} - -export class AppInnerToolError extends Data.TaggedError("AppInnerToolError")<{ - readonly address: string; - readonly innerMessage: string; - readonly code?: string; -}> {} - -export const appInnerToolFailureMessage = (error: AppInnerToolError): string => - `Inner tool ${error.address} failed: "${error.innerMessage}"`; - -export interface ConnectionCandidate { - readonly address: string; - readonly integration: string; -} - -export interface ClientResolver { - readonly listConnections: (input: { - readonly integration: string; - }) => Effect.Effect; - readonly resolveConnection: (input: { - readonly connection: string; - }) => Effect.Effect; - readonly call: (input: { - readonly integration: string; - readonly connection: string; - readonly path: readonly string[]; - readonly args: unknown; - readonly invokeOptions?: InvokeOptions; - }) => Effect.Effect; -} - -export interface ResolvedBindings { - readonly input: Record; - readonly bindings: Readonly>; -} - -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === "object" && !Array.isArray(value); - -const resolveRequested = ( - field: string, - decl: IntegrationDecl, - requested: string, - resolver: ClientResolver, -): Effect.Effect => - Effect.gen(function* () { - const connection = yield* resolver.resolveConnection({ connection: requested }); - if (!connection) { - const candidates = yield* resolver.listConnections({ integration: decl.slug }); - return yield* new BindingError({ - role: field, - integration: decl.slug, - requestedConnection: requested, - message: `unknown connection "${requested}" for integration field "${field}" (${decl.slug})${candidates.length > 0 ? `; choose one of ${candidates.map((candidate) => candidate.address).join(", ")}` : ""}`, - }); - } - if (connection.integration !== decl.slug) { - return yield* new BindingError({ - role: field, - integration: decl.slug, - requestedConnection: requested, - message: `connection "${requested}" belongs to integration "${connection.integration}", not "${decl.slug}"`, - }); - } - return connection.address; - }); - -const resolveManyRequested = ( - field: string, - decl: IntegrationDecl, - requested: readonly unknown[], - resolver: ClientResolver, -): Effect.Effect => - Effect.forEach(requested, (item) => { - if (typeof item !== "string" || item.length === 0) { - return Effect.fail( - new BindingError({ - role: field, - integration: decl.slug, - message: `connection set for integration field "${field}" (${decl.slug}) must contain non-empty strings`, - }), - ); - } - return resolveRequested(field, decl, item, resolver); - }); - -export const resolveIntegrationBindings = ( - declared: Readonly>, - args: unknown, - resolver: ClientResolver, -): Effect.Effect => - Effect.gen(function* () { - const payload = isRecord(args) ? args : {}; - const input: Record = { ...payload }; - const bindings: Record = {}; - for (const [field, decl] of Object.entries(declared)) { - const raw = payload[field]; - if (decl.mode === "many") { - if (!Array.isArray(raw)) { - return yield* new BindingError({ - role: field, - integration: decl.slug, - message: `connection set for integration field "${field}" (${decl.slug}) must be an array`, - }); - } - bindings[field] = yield* resolveManyRequested(field, decl, raw, resolver); - delete input[field]; - continue; - } - if (typeof raw !== "string" || raw.length === 0) { - return yield* new BindingError({ - role: field, - integration: decl.slug, - message: `connection for integration field "${field}" (${decl.slug}) must be a non-empty string`, - ...(typeof raw === "string" ? { requestedConnection: raw } : {}), - }); - } - bindings[field] = yield* resolveRequested(field, decl, raw, resolver); - delete input[field]; - } - return { input, bindings }; - }); - -export const buildBridge = (input: { - readonly declared: Readonly>; - readonly bindings: Readonly>; - readonly resolver: ClientResolver; - readonly invokeOptions?: InvokeOptions; -}): AppToolBridge => ({ - call: (toolPath, args) => { - const [root, ...path] = toolPath.split("."); - const [field, rawIndex] = (root ?? "").split("#"); - const decl = input.declared[field ?? ""]; - const binding = input.bindings[field ?? ""]; - const connection = - rawIndex === undefined - ? typeof binding === "string" - ? binding - : undefined - : Array.isArray(binding) - ? binding[Number(rawIndex)] - : undefined; - if (!field || !decl || !connection || path.length === 0) { - return Effect.runPromise( - Effect.fail( - new BindingError({ - role: field ?? "", - integration: decl?.slug ?? "", - message: `undeclared integration call: ${toolPath}`, - }), - ), - ); - } - return Effect.runPromise( - input.resolver.call({ - integration: decl.slug, - connection, - path, - args, - invokeOptions: input.invokeOptions, - }), - ); - }, -}); diff --git a/packages/plugins/apps/src/plugin/handlers.ts b/packages/plugins/apps/src/plugin/handlers.ts deleted file mode 100644 index 6e0a539f7d..0000000000 --- a/packages/plugins/apps/src/plugin/handlers.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* oxlint-disable executor/no-effect-escape-hatch -- boundary: HttpApi handler adapter maps plugin extension failures into InternalError */ -import { Context, Effect } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; - -import { addGroup, capture } from "@executor-js/api"; -import { InternalError } from "@executor-js/sdk/shared"; -import type { AppsExtension } from "./apps-plugin"; -import { AppsBaseGroup, AppsGroup } from "./routes"; - -export class AppsExtensionService extends Context.Service()( - "AppsExtensionService", -) {} - -const ExecutorApiWithAppsBase = addGroup(AppsBaseGroup); -const ExecutorApiWithApps = addGroup(AppsGroup); - -const captureUnexpected = ( - effect: Effect.Effect, -): Effect.Effect => - capture(effect.pipe(Effect.catch((cause: unknown) => Effect.die(cause)))); - -export const AppsBaseHandlers = HttpApiBuilder.group(ExecutorApiWithAppsBase, "apps", (handlers) => - handlers - .handle("listSources", () => - captureUnexpected( - Effect.gen(function* () { - const ext = yield* AppsExtensionService; - const sources = yield* ext.listSources(); - return { sources }; - }), - ), - ) - .handle("createSource", ({ payload }) => - captureUnexpected( - Effect.gen(function* () { - const ext = yield* AppsExtensionService; - const source = yield* ext.createSource(payload); - return { source }; - }), - ), - ) - .handle("getSource", ({ params }) => - captureUnexpected( - Effect.gen(function* () { - const ext = yield* AppsExtensionService; - const source = yield* ext.getSource(params.slug); - return { source }; - }), - ), - ) - .handle("deleteSource", ({ params }) => - captureUnexpected( - Effect.gen(function* () { - const ext = yield* AppsExtensionService; - return yield* ext.deleteSource(params.slug); - }), - ), - ) - .handle("syncSource", ({ params }) => - captureUnexpected( - Effect.gen(function* () { - const ext = yield* AppsExtensionService; - return yield* ext.syncSource(params.slug); - }), - ), - ), -); - -export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) => - handlers - .handle("listSources", () => - captureUnexpected( - Effect.gen(function* () { - const ext = yield* AppsExtensionService; - const sources = yield* ext.listSources(); - return { sources }; - }), - ), - ) - .handle("createSource", ({ payload }) => - captureUnexpected( - Effect.gen(function* () { - const ext = yield* AppsExtensionService; - const source = yield* ext.createSource(payload); - return { source }; - }), - ), - ) - .handle("getSource", ({ params }) => - captureUnexpected( - Effect.gen(function* () { - const ext = yield* AppsExtensionService; - const source = yield* ext.getSource(params.slug); - return { source }; - }), - ), - ) - .handle("deleteSource", ({ params }) => - captureUnexpected( - Effect.gen(function* () { - const ext = yield* AppsExtensionService; - return yield* ext.deleteSource(params.slug); - }), - ), - ) - .handle("syncSource", ({ params }) => - captureUnexpected( - Effect.gen(function* () { - const ext = yield* AppsExtensionService; - return yield* ext.syncSource(params.slug); - }), - ), - ) - .handle("listDirs", ({ query }) => - captureUnexpected( - Effect.gen(function* () { - const ext = yield* AppsExtensionService; - return yield* ext.listDirs({ - ...(query.path ? { path: query.path } : {}), - includeHidden: query.includeHidden === "true" || query.includeHidden === "1", - }); - }), - ), - ), -); - -export const appsHandlersForSourceKinds = ( - sourceKinds: readonly ("git" | "local-directory")[] | undefined, -) => ((sourceKinds?.includes("local-directory") ?? true) ? AppsHandlers : AppsBaseHandlers); diff --git a/packages/plugins/apps/src/plugin/resolver.ts b/packages/plugins/apps/src/plugin/resolver.ts deleted file mode 100644 index 882d81486b..0000000000 --- a/packages/plugins/apps/src/plugin/resolver.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { Effect, Predicate } from "effect"; -import { - ConnectionName, - IntegrationSlug, - ToolAddress, - isToolResult, - type ExecuteError, - type ConnectionRef, - type Owner, - type PluginCtx, -} from "@executor-js/sdk"; - -import { - AppInnerToolError, - BindingError, - type ClientResolver, - type ConnectionCandidate, -} from "./bindings"; - -export interface AppsResolverPluginCtx { - readonly connections: Pick; - readonly execute: PluginCtx["execute"]; -} - -const parseConnectionAddress = (address: string): ConnectionRef | null => { - const parts = address.split("."); - if (parts.length !== 4 || parts[0] !== "tools") return null; - const [, integration, owner, name] = parts; - if (!integration || !name) return null; - if (owner !== "org" && owner !== "user") return null; - return { - owner: owner as Owner, - integration: IntegrationSlug.make(integration), - name: ConnectionName.make(name), - }; -}; - -const toCandidate = (connection: { - readonly address?: unknown; - readonly integration: unknown; - readonly owner?: unknown; - readonly name?: unknown; -}): ConnectionCandidate => ({ - address: - typeof connection.address === "string" - ? connection.address - : `tools.${String(connection.integration)}.${String(connection.owner)}.${String( - connection.name, - )}`, - integration: String(connection.integration), -}); - -const innerMessageFromCause = (cause: ExecuteError): string => { - if ( - Predicate.isTagged("ToolInvocationError")(cause) || - Predicate.isTagged("CredentialResolutionError")(cause) || - Predicate.isTagged("StorageError")(cause) - ) { - // oxlint-disable-next-line executor/no-unknown-error-message -- typed ExecuteError variants expose message as caller-facing failure text - return cause.message; - } - if (Predicate.isTagged("ToolNotFoundError")(cause)) return `Tool not found: ${cause.address}`; - if (Predicate.isTagged("ToolBlockedError")(cause)) return `Tool blocked: ${cause.address}`; - if (Predicate.isTagged("PluginNotLoadedError")(cause)) { - return `Plugin not loaded for tool: ${cause.address}`; - } - if (Predicate.isTagged("NoHandlerError")(cause)) return `No handler for tool: ${cause.address}`; - if (Predicate.isTagged("ConnectionNotFoundError")(cause)) { - return `Connection not found: ${cause.integration}.${cause.owner}.${cause.name}`; - } - if (Predicate.isTagged("CredentialProviderNotRegisteredError")(cause)) { - return `Credential provider not registered: ${cause.provider}`; - } - if (Predicate.isTagged("ElicitationDeclinedError")(cause)) { - return `Tool approval ${cause.action === "cancel" ? "cancelled" : "declined"}: ${cause.address}`; - } - if (Predicate.isTagged("UniqueViolationError")(cause)) return "storage unique violation"; - return "inner tool failed"; -}; - -export const makePluginCtxAppsResolver = (input: { - readonly ctx: AppsResolverPluginCtx; -}): ClientResolver => ({ - listConnections: ({ integration }) => - input.ctx.connections.list({ integration: IntegrationSlug.make(integration) }).pipe( - Effect.map((connections) => connections.map(toCandidate)), - Effect.mapError( - () => - new BindingError({ - role: integration, - integration, - message: `failed to list ${integration} connections`, - }), - ), - ), - resolveConnection: ({ connection }) => - Effect.gen(function* () { - const ref = parseConnectionAddress(connection); - if (!ref) return null; - const row = yield* input.ctx.connections.get(ref).pipe( - Effect.mapError( - () => - new BindingError({ - role: String(ref.integration), - integration: String(ref.integration), - requestedConnection: connection, - message: `failed to resolve connection ${connection}`, - }), - ), - ); - return row ? toCandidate(row) : null; - }), - call: ({ integration, connection, path, args, invokeOptions }) => - Effect.gen(function* () { - const ref = parseConnectionAddress(connection); - if (!ref) { - return yield* new BindingError({ - role: integration, - integration, - requestedConnection: connection, - message: `invalid connection address ${connection}`, - }); - } - if (String(ref.integration) !== integration) { - return yield* new BindingError({ - role: integration, - integration, - requestedConnection: connection, - message: `connection "${connection}" belongs to integration "${ref.integration}", not "${integration}"`, - }); - } - const address = ToolAddress.make(`${connection}.${path.join(".")}`); - const result = yield* input.ctx.execute(address, args, invokeOptions).pipe( - Effect.mapError( - (cause) => - new AppInnerToolError({ - address, - innerMessage: innerMessageFromCause(cause), - }), - ), - ); - if (isToolResult(result)) { - if (result.ok) return result.data; - return yield* new AppInnerToolError({ - address, - code: result.error.code, - innerMessage: result.error.message, - }); - } - return result; - }), -}); diff --git a/packages/plugins/apps/src/plugin/routes.ts b/packages/plugins/apps/src/plugin/routes.ts deleted file mode 100644 index 60672f7850..0000000000 --- a/packages/plugins/apps/src/plugin/routes.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Schema } from "effect"; -import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; -import { InternalError } from "@executor-js/sdk/shared"; - -const SourceSlugParams = { - slug: Schema.String, -}; - -const CreateSourcePayload = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("git"), - slug: Schema.optional(Schema.String), - app: Schema.optional(Schema.String), - url: Schema.String, - ref: Schema.optional(Schema.String), - token: Schema.optional(Schema.String), - }), - Schema.Struct({ - kind: Schema.Literal("local-directory"), - slug: Schema.optional(Schema.String), - app: Schema.optional(Schema.String), - path: Schema.String, - }), -]); - -const SyncDiagnostic = Schema.Struct({ - stage: Schema.Literals(["source", "discover", "bundle", "collect", "project"]), - message: Schema.String, - diagnostics: Schema.optional( - Schema.Array(Schema.Struct({ path: Schema.String, message: Schema.String })), - ), -}); - -const SourceStatus = Schema.Union([ - Schema.Struct({ type: Schema.Literal("pending") }), - Schema.Struct({ - type: Schema.Literals(["published", "up-to-date"]), - at: Schema.Number, - tools: Schema.Array(Schema.String), - }), - Schema.Struct({ - type: Schema.Literal("failed"), - at: Schema.Number, - errors: Schema.Array(SyncDiagnostic), - }), -]); - -const SourceRecord = Schema.Struct({ - slug: Schema.String, - app: Schema.String, - kind: Schema.Literals(["git", "local-directory"]), - config: CreateSourcePayload, - sourceRef: Schema.optional(Schema.String), - description: Schema.optional(Schema.String), - status: SourceStatus, - updatedAt: Schema.Number, -}); - -const SourcesResponse = Schema.Struct({ sources: Schema.Array(SourceRecord) }); -const SourceResponse = Schema.Struct({ source: Schema.NullOr(SourceRecord) }); -const CreateSourceResponse = Schema.Struct({ source: SourceRecord }); -const DeleteSourceResponse = Schema.Struct({ removed: Schema.Boolean }); -const SyncSourceResponse = Schema.Struct({ - status: Schema.Literals(["published", "up-to-date", "failed"]), - sourceRef: Schema.optional(Schema.String), - tools: Schema.Array(Schema.String), - errors: Schema.optional(Schema.Array(SyncDiagnostic)), -}); - -const ListDirsQuery = Schema.Struct({ - path: Schema.optional(Schema.String), - includeHidden: Schema.optional(Schema.String), -}); -const ListDirsResponse = Schema.Struct({ - path: Schema.String, - parent: Schema.NullOr(Schema.String), - dirs: Schema.Array( - Schema.Struct({ - name: Schema.String, - path: Schema.String, - isSymlink: Schema.Boolean, - hasTools: Schema.Boolean, - }), - ), - source: Schema.Struct({ - toolFiles: Schema.Array(Schema.String), - skipped: Schema.Array(Schema.String), - hasPackageJson: Schema.Boolean, - }), -}); - -const DomainErrors = [InternalError] as const; - -export const AppsBaseGroup = HttpApiGroup.make("apps") - .add( - HttpApiEndpoint.get("listSources", "/apps/sources", { - success: SourcesResponse, - error: DomainErrors, - }), - ) - .add( - HttpApiEndpoint.post("createSource", "/apps/sources", { - payload: CreateSourcePayload, - success: CreateSourceResponse, - error: DomainErrors, - }), - ) - .add( - HttpApiEndpoint.get("getSource", "/apps/sources/:slug", { - params: SourceSlugParams, - success: SourceResponse, - error: DomainErrors, - }), - ) - .add( - HttpApiEndpoint.delete("deleteSource", "/apps/sources/:slug", { - params: SourceSlugParams, - success: DeleteSourceResponse, - error: DomainErrors, - }), - ) - .add( - HttpApiEndpoint.post("syncSource", "/apps/sources/:slug/sync", { - params: SourceSlugParams, - success: SyncSourceResponse, - error: DomainErrors, - }), - ); - -export const AppsGroup = AppsBaseGroup.add( - // Local-directory browsing names directories on the host running Executor. - // It is only safe for the local single-user host, and the plugin sourceKinds - // gate keeps shared selfhost and cloud deployments from registering it. - HttpApiEndpoint.get("listDirs", "/apps/fs/dirs", { - query: ListDirsQuery, - success: ListDirsResponse, - error: DomainErrors, - }), -); - -export const appsGroupForSourceKinds = ( - sourceKinds: readonly ("git" | "local-directory")[] | undefined, -) => ((sourceKinds?.includes("local-directory") ?? true) ? AppsGroup : AppsBaseGroup); diff --git a/packages/plugins/apps/src/plugin/store.test.ts b/packages/plugins/apps/src/plugin/store.test.ts deleted file mode 100644 index b72ebe8da9..0000000000 --- a/packages/plugins/apps/src/plugin/store.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { - makeInMemoryBlobStore, - pluginBlobStore, - type PluginStorageCollectionDefinition, - type PluginStorageEntry, - type PluginStorageFacade, -} from "@executor-js/sdk"; - -import type { AppDescriptor } from "../pipeline/descriptor"; -import { descriptorCollection, makeAppsStore, toolCollection } from "./store"; - -const entry = (input: { - owner: "org" | "user"; - collection: string; - key: string; - data: T; -}): PluginStorageEntry => ({ - id: `${input.collection}:${input.key}`, - owner: input.owner, - pluginId: "apps", - collection: input.collection, - key: input.key, - data: input.data, - createdAt: new Date(0), - updatedAt: new Date(0), -}); - -const makeMemoryPluginStorage = (): PluginStorageFacade => { - const rows = new Map>(); - const rowKey = (owner: string, collection: string, key: string) => - `${owner}:${collection}:${key}`; - const put = (input: { - readonly owner: "org" | "user"; - readonly collection: string; - readonly key: string; - readonly data: object; - }) => - Effect.sync(() => { - const next = entry({ - owner: input.owner, - collection: input.collection, - key: input.key, - data: input.data, - }); - rows.set(rowKey(input.owner, input.collection, input.key), next); - return next; - }); - return { - collection: ( - definition: TDefinition, - ) => ({ - get: ({ key }) => - Effect.sync(() => { - for (const row of rows.values()) { - if (row.collection === definition.name && row.key === key) return row as never; - } - return null; - }), - getForOwner: ({ owner, key }) => - Effect.sync( - () => (rows.get(rowKey(owner, definition.name, key)) as never | undefined) ?? null, - ), - list: ({ keyPrefix } = {}) => - Effect.sync( - () => - [...rows.values()].filter( - (row) => - row.collection === definition.name && - (keyPrefix === undefined || row.key.startsWith(keyPrefix)), - ) as never, - ), - put: (input) => put({ ...input, collection: definition.name }) as never, - query: (input = {}) => - Effect.sync(() => { - let out = [...rows.values()].filter((row) => row.collection === definition.name); - if (input.where) { - out = out.filter((row) => - Object.entries(input.where ?? {}).every(([field, expected]) => { - const actual = (row.data as Record)[field]; - return typeof expected === "object" && expected !== null && "eq" in expected - ? actual === expected.eq - : actual === expected; - }), - ); - } - return (input.limit ? out.slice(0, input.limit) : out) as never; - }), - count: () => Effect.sync(() => 0), - remove: ({ owner, key }) => - Effect.sync(() => { - rows.delete(rowKey(owner, definition.name, key)); - }), - }), - get: ({ collection, key }) => - Effect.sync(() => { - for (const row of rows.values()) { - if (row.collection === collection && row.key === key) return row as never; - } - return null; - }), - getForOwner: ({ owner, collection, key }) => - Effect.sync(() => (rows.get(rowKey(owner, collection, key)) as never | undefined) ?? null), - list: ({ collection, keyPrefix }) => - Effect.sync( - () => - [...rows.values()].filter( - (row) => - row.collection === collection && - (keyPrefix === undefined || row.key.startsWith(keyPrefix)), - ) as never, - ), - put: (input) => put(input as never) as never, - putMany: ({ owner, entries }) => - Effect.sync(() => { - for (const item of entries) { - const next = entry({ - owner, - collection: item.collection, - key: item.key, - data: item.data, - }); - rows.set(rowKey(owner, item.collection, item.key), next); - } - }), - remove: ({ owner, collection, key }) => - Effect.sync(() => { - rows.delete(rowKey(owner, collection, key)); - }), - removeMany: ({ owner, entries }) => - Effect.sync(() => { - for (const item of entries) rows.delete(rowKey(owner, item.collection, item.key)); - }), - }; -}; - -const descriptor = (tools: readonly AppDescriptor["tools"][number][]): AppDescriptor => ({ - version: 6, - app: "crm", - sourceRef: "sha-1", - publishedAt: 1, - toolchain: { - bundler: { name: "esbuild", version: "0" }, - executor: { name: "test", version: "0" }, - target: "es2022", - }, - tools, - workflows: [], - ui: [], - skills: [], - skipped: [], -}); - -const tool = (name: string): AppDescriptor["tools"][number] => ({ - name, - sourcePath: `tools/${name}.ts`, - bundleKey: `bundle:${name}`, - source: { path: `tools/${name}.ts`, sourceHash: name }, - description: name, - integrations: {}, -}); - -describe("apps store", () => { - it.effect("tombstones removed tools", () => - Effect.gen(function* () { - const store = makeAppsStore({ - blobs: pluginBlobStore(makeInMemoryBlobStore(), { org: "tenant", user: null }, "apps"), - pluginStorage: makeMemoryPluginStorage(), - }); - yield* store.putPublished( - descriptor([tool("first"), tool("second")]), - "descriptor", - "org", - null, - ); - yield* store.putPublished(descriptor([tool("first")]), "descriptor-2", "org", "sha-1"); - const active = yield* store.listActiveTools(); - expect(active.map((item) => item.name)).toEqual(["first"]); - expect(active.map((item) => item.app)).toEqual(["crm"]); - }), - ); - - it.effect("removes a published app catalog", () => - Effect.gen(function* () { - const store = makeAppsStore({ - blobs: pluginBlobStore(makeInMemoryBlobStore(), { org: "tenant", user: null }, "apps"), - pluginStorage: makeMemoryPluginStorage(), - }); - yield* store.putPublished( - descriptor([tool("first"), tool("second")]), - "descriptor", - "org", - null, - ); - yield* store.removePublished("crm", "org"); - const active = yield* store.listActiveTools(); - const descriptorRecord = yield* store.getDescriptorRecord("crm"); - expect(active).toEqual([]); - expect(descriptorRecord).toBeNull(); - }), - ); - - it.effect("keeps owner blob partitions isolated", () => - Effect.gen(function* () { - const blobStore = makeInMemoryBlobStore(); - const orgStore = makeAppsStore({ - blobs: pluginBlobStore(blobStore, { org: "tenant-a", user: null }, "apps"), - pluginStorage: makeMemoryPluginStorage(), - }); - const otherStore = makeAppsStore({ - blobs: pluginBlobStore(blobStore, { org: "tenant-b", user: null }, "apps"), - pluginStorage: makeMemoryPluginStorage(), - }); - const key = yield* orgStore.putBlob("same app", "org"); - expect(yield* orgStore.getBlob(key)).toBe("same app"); - expect(yield* otherStore.getBlob(key)).toBeNull(); - }), - ); - - it("declares plugin storage collections", () => { - expect(descriptorCollection.name).toBe("apps_descriptors"); - expect(toolCollection.name).toBe("apps_tools"); - }); -}); diff --git a/packages/plugins/apps/src/plugin/store.ts b/packages/plugins/apps/src/plugin/store.ts deleted file mode 100644 index 4f8dffe446..0000000000 --- a/packages/plugins/apps/src/plugin/store.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { Data, Effect } from "effect"; -import { - definePluginStorageCollection, - sha256Hex, - type Owner, - type PluginBlobStore, - type PluginStorageFacade, - type StorageFailure, -} from "@executor-js/sdk"; - -import type { AppDescriptor } from "../pipeline/descriptor"; -import type { SyncDiagnostic } from "../source/app-source"; - -export class AppPublishConflictError extends Data.TaggedError("AppPublishConflictError")<{ - readonly app: string; - readonly expectedSourceRef: string | null; - readonly actualSourceRef: string | null; -}> {} - -export const descriptorCollection = definePluginStorageCollection("apps_descriptors", { - Type: {} as { - readonly app: string; - readonly sourceRef: string; - readonly descriptorKey: string; - readonly publishedAt: number; - readonly toolchain: AppDescriptor["toolchain"]; - }, -}); - -export const toolCollection = definePluginStorageCollection( - "apps_tools", - { - Type: {} as { - readonly app: string; - readonly name: string; - readonly sourceRef: string; - readonly descriptorKey: string; - readonly bundleKey: string; - readonly description: string; - readonly inputSchema?: unknown; - readonly outputSchema?: unknown; - readonly integrations: AppDescriptor["tools"][number]["integrations"]; - readonly annotations?: AppDescriptor["tools"][number]["annotations"]; - readonly tombstoned: boolean; - readonly updatedAt: number; - }, - }, - { indexes: ["app", "name", "tombstoned"] }, -); - -export const sourceCollection = definePluginStorageCollection( - "apps_sources", - { - Type: {} as AppSourceRecord, - }, - { indexes: ["kind", "app"] }, -); - -export type AppSourceKind = "git" | "local-directory"; - -export type AppSourceConfig = - | { - readonly kind: "git"; - readonly url: string; - readonly ref?: string; - readonly tokenProvider?: string; - readonly tokenItemId?: string; - } - | { - readonly kind: "local-directory"; - readonly path: string; - }; - -export interface AppSourceRecord { - readonly slug: string; - readonly app: string; - readonly kind: AppSourceKind; - readonly config: AppSourceConfig; - readonly sourceRef?: string; - readonly description?: string; - readonly status: - | { readonly type: "pending" } - | { readonly type: "published"; readonly at: number; readonly tools: readonly string[] } - | { readonly type: "up-to-date"; readonly at: number; readonly tools: readonly string[] } - | { readonly type: "failed"; readonly at: number; readonly errors: readonly SyncDiagnostic[] }; - readonly updatedAt: number; -} - -export interface AppsStore { - readonly putBlob: (body: string, owner: Owner) => Effect.Effect; - readonly getBlob: (key: string) => Effect.Effect; - readonly getDescriptorRecord: (app: string) => Effect.Effect< - { - readonly sourceRef: string; - readonly descriptorKey: string; - } | null, - StorageFailure - >; - readonly putPublished: ( - descriptor: AppDescriptor, - descriptorKey: string, - owner: Owner, - expectedSourceRef: string | null, - ) => Effect.Effect; - readonly removePublished: (app: string, owner: Owner) => Effect.Effect; - readonly listActiveTools: () => Effect.Effect< - readonly (AppDescriptor["tools"][number] & { readonly app: string })[], - StorageFailure - >; - readonly getTool: (name: string) => Effect.Effect< - { - readonly app: string; - readonly name: string; - readonly bundleKey: string; - readonly description: string; - readonly inputSchema?: unknown; - readonly outputSchema?: unknown; - readonly integrations: AppDescriptor["tools"][number]["integrations"]; - readonly annotations?: AppDescriptor["tools"][number]["annotations"]; - } | null, - StorageFailure - >; - readonly getToolForApp: ( - app: string, - name: string, - ) => Effect.Effect< - { - readonly app: string; - readonly name: string; - readonly bundleKey: string; - readonly description: string; - readonly inputSchema?: unknown; - readonly outputSchema?: unknown; - readonly integrations: AppDescriptor["tools"][number]["integrations"]; - readonly annotations?: AppDescriptor["tools"][number]["annotations"]; - } | null, - StorageFailure - >; - readonly putSource: ( - record: AppSourceRecord, - owner: Owner, - ) => Effect.Effect; - readonly listSources: () => Effect.Effect; - readonly getSource: (slug: string) => Effect.Effect; - readonly removeSource: (slug: string, owner: Owner) => Effect.Effect; -} - -interface PutManyEntry { - readonly collection: string; - readonly key: string; - readonly data: unknown; -} - -export const makeAppsStore = (input: { - readonly blobs: PluginBlobStore; - readonly pluginStorage: PluginStorageFacade; -}): AppsStore => { - const descriptors = input.pluginStorage.collection(descriptorCollection); - const tools = input.pluginStorage.collection(toolCollection); - const sources = input.pluginStorage.collection(sourceCollection); - const keyForTool = (app: string, name: string) => `${app}:${name}`; - return { - putBlob: (body, owner) => - sha256Hex(body).pipe( - Effect.flatMap((hash) => - input.blobs.put(`apps/${hash}`, body, { owner }).pipe(Effect.as(`apps/${hash}`)), - ), - ), - getBlob: (key) => input.blobs.get(key), - getDescriptorRecord: (app) => - descriptors.get({ key: app }).pipe( - Effect.map((entry) => - entry - ? { - sourceRef: entry.data.sourceRef, - descriptorKey: entry.data.descriptorKey, - } - : null, - ), - ), - putPublished: (descriptor, descriptorKey, owner, expectedSourceRef) => - Effect.gen(function* () { - const current = yield* descriptors.get({ key: descriptor.app }); - const actualSourceRef = current?.data.sourceRef ?? null; - if (actualSourceRef !== expectedSourceRef) { - return yield* new AppPublishConflictError({ - app: descriptor.app, - expectedSourceRef, - actualSourceRef, - }); - } - const now = descriptor.publishedAt; - const existing = yield* tools.query({ where: { app: descriptor.app } }); - const activeNames = new Set(descriptor.tools.map((tool) => tool.name)); - const entries: PutManyEntry[] = descriptor.tools.map((tool) => ({ - collection: toolCollection.name, - key: keyForTool(descriptor.app, tool.name), - data: { - app: descriptor.app, - name: tool.name, - sourceRef: descriptor.sourceRef, - descriptorKey, - bundleKey: tool.bundleKey, - description: tool.description, - inputSchema: tool.inputSchema, - outputSchema: tool.outputSchema, - integrations: tool.integrations, - annotations: tool.annotations, - tombstoned: false, - updatedAt: now, - }, - })); - for (const entry of existing) { - if (!activeNames.has(entry.data.name) && !entry.data.tombstoned) { - entries.push({ - collection: toolCollection.name, - key: entry.key, - data: { ...entry.data, tombstoned: true, updatedAt: now }, - }); - } - } - yield* input.pluginStorage.putMany({ owner, entries }); - yield* descriptors.put({ - owner, - key: descriptor.app, - data: { - app: descriptor.app, - sourceRef: descriptor.sourceRef, - descriptorKey, - publishedAt: descriptor.publishedAt, - toolchain: descriptor.toolchain, - }, - }); - }), - removePublished: (app, owner) => - Effect.gen(function* () { - const existing = yield* tools.query({ where: { app } }); - const now = Date.now(); - const entries: PutManyEntry[] = existing - .filter((entry) => !entry.data.tombstoned) - .map((entry) => ({ - collection: toolCollection.name, - key: entry.key, - data: { ...entry.data, tombstoned: true, updatedAt: now }, - })); - if (entries.length > 0) { - yield* input.pluginStorage.putMany({ owner, entries }); - } - yield* descriptors.remove({ owner, key: app }); - }), - listActiveTools: () => - tools.query({ where: { tombstoned: false } }).pipe( - Effect.map((entries) => - entries.map((entry) => ({ - app: entry.data.app, - name: entry.data.name, - sourcePath: "", - source: { path: "", sourceHash: "" }, - bundleKey: entry.data.bundleKey, - description: entry.data.description, - integrations: entry.data.integrations, - inputSchema: entry.data.inputSchema, - outputSchema: entry.data.outputSchema, - annotations: entry.data.annotations, - })), - ), - ), - getTool: (name) => - tools.query({ where: { name, tombstoned: false }, limit: 1 }).pipe( - Effect.map((entries) => { - const entry = entries[0]; - return entry - ? { - app: entry.data.app, - name: entry.data.name, - bundleKey: entry.data.bundleKey, - description: entry.data.description, - inputSchema: entry.data.inputSchema, - outputSchema: entry.data.outputSchema, - integrations: entry.data.integrations, - annotations: entry.data.annotations, - } - : null; - }), - ), - getToolForApp: (app, name) => - tools.query({ where: { app, name, tombstoned: false }, limit: 1 }).pipe( - Effect.map((entries) => { - const entry = entries[0]; - return entry - ? { - app: entry.data.app, - name: entry.data.name, - bundleKey: entry.data.bundleKey, - description: entry.data.description, - inputSchema: entry.data.inputSchema, - outputSchema: entry.data.outputSchema, - integrations: entry.data.integrations, - annotations: entry.data.annotations, - } - : null; - }), - ), - putSource: (record, owner) => - sources.put({ owner, key: record.slug, data: record }).pipe(Effect.asVoid), - listSources: () => - sources.list().pipe(Effect.map((entries) => entries.map((entry) => entry.data))), - getSource: (slug) => - sources.get({ key: slug }).pipe(Effect.map((entry) => entry?.data ?? null)), - removeSource: (slug, owner) => sources.remove({ owner, key: slug }), - }; -}; diff --git a/packages/plugins/apps/src/react/AddCustomToolsSource.tsx b/packages/plugins/apps/src/react/AddCustomToolsSource.tsx deleted file mode 100644 index 48eceeaaa0..0000000000 --- a/packages/plugins/apps/src/react/AddCustomToolsSource.tsx +++ /dev/null @@ -1,459 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { Effect, Exit } from "effect"; - -import { Button } from "@executor-js/react/components/button"; -import { - CardStack, - CardStackContent, - CardStackEntryField, -} from "@executor-js/react/components/card-stack"; -import { FloatActions } from "@executor-js/react/components/float-actions"; -import { Input } from "@executor-js/react/components/input"; -import { Checkbox } from "@executor-js/react/components/checkbox"; -import { Label } from "@executor-js/react/components/label"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@executor-js/react/components/dialog"; -import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add"; - -import { - createCustomToolSourceEffect, - formatSyncErrors, - listCustomToolDirectoriesEffect, - parseGitSourceUrl, - slugifyCustomToolsAppName, - suggestCustomToolsAppName, - syncCustomToolSourceEffect, - validateCustomToolsAppSlug, - validateGitSourceUrl, - type CustomToolsDirectoryListing, - type AppSourceKind, -} from "./custom-tools-client"; -import { directoryBrowserRows, directorySourceVerdict } from "./source-panel-model"; - -export default function AddCustomToolsSource(props: { - readonly onComplete: (slug?: string) => void; - readonly onCancel: () => void; - readonly initialUrl?: string; - readonly initialNamespace?: string; - readonly sourceKinds?: readonly AppSourceKind[]; -}) { - const sourceKinds = props.sourceKinds ?? ["git"]; - const allowLocalDirectory = sourceKinds.includes("local-directory"); - const initialKind = - props.initialUrl && props.initialUrl.startsWith("/") && allowLocalDirectory - ? "local-directory" - : "git"; - const [kind, setKind] = useState(initialKind); - const [url, setUrl] = useState(props.initialUrl ?? ""); - const [path, setPath] = useState( - initialKind === "local-directory" ? (props.initialUrl ?? "") : "", - ); - const [ref, setRef] = useState(""); - const [name, setName] = useState( - props.initialNamespace ? slugifyCustomToolsAppName(props.initialNamespace) : "", - ); - const [nameTouched, setNameTouched] = useState(props.initialNamespace !== undefined); - const [token, setToken] = useState(""); - const [fieldError, setFieldError] = useState(null); - const [nameError, setNameError] = useState(null); - const [syncing, setSyncing] = useState(false); - const [syncError, setSyncError] = useState(null); - const [browseOpen, setBrowseOpen] = useState(false); - const [pathListing, setPathListing] = useState(null); - - const sourceValue = kind === "git" ? url : path; - const effectiveName = useMemo( - () => (nameTouched ? name : slugifyCustomToolsAppName(suggestCustomToolsAppName(sourceValue))), - [name, nameTouched, sourceValue], - ); - const slug = effectiveName; - const slugAlreadyExists = useSlugAlreadyExists(slug); - - useEffect(() => { - if (kind !== "local-directory" || !path.trim().startsWith("/")) { - setPathListing(null); - return; - } - let active = true; - const timeout = window.setTimeout(() => { - void Effect.runPromiseExit(listCustomToolDirectoriesEffect({ path: path.trim() })).then( - (exit) => { - if (!active) return; - setPathListing(Exit.isSuccess(exit) ? exit.value : null); - }, - ); - }, 300); - return () => { - active = false; - window.clearTimeout(timeout); - }; - }, [kind, path]); - - const submit = async () => { - const nextFieldError = - kind === "git" - ? validateGitSourceUrl(url) - : path.trim().startsWith("/") - ? null - : "Enter an absolute directory path."; - const nextNameError = validateCustomToolsAppSlug(slug); - setFieldError(nextFieldError); - setNameError(nextNameError); - setSyncError(null); - if (nextFieldError || nextNameError) return; - if (slugAlreadyExists) { - setSyncError(`An integration named "${slug}" already exists. Choose another source name.`); - return; - } - - setSyncing(true); - const parsedGitUrl = kind === "git" ? parseGitSourceUrl(url) : null; - const createExit = await Effect.runPromiseExit( - createCustomToolSourceEffect( - kind === "git" - ? { - kind: "git", - slug, - app: slug, - url: parsedGitUrl?.ok ? parsedGitUrl.url : url.trim(), - ...(ref.trim() ? { ref: ref.trim() } : {}), - ...(token.trim() ? { token: token.trim() } : {}), - } - : { kind: "local-directory", slug, app: slug, path: path.trim() }, - ), - ); - if (Exit.isFailure(createExit)) { - setSyncError("Failed to create custom tools source."); - setSyncing(false); - return; - } - const syncExit = await Effect.runPromiseExit( - syncCustomToolSourceEffect(createExit.value.source.slug), - ); - if (Exit.isFailure(syncExit)) { - setSyncError("Failed to sync custom tools source."); - setSyncing(false); - return; - } - const result = syncExit.value; - if (result.status === "failed") { - setSyncError(formatSyncErrors(result).join("\n") || "Sync failed."); - setSyncing(false); - return; - } - setSyncing(false); - props.onComplete(createExit.value.source.app); - }; - - return ( -
-
-

Add custom tools

-

- Sync tools from a Git repository, then publish them to the Apps catalog. -

-
- - - - {allowLocalDirectory && ( - -
- - -
-
- )} - - {kind === "git" ? ( - <> - -
- { - setUrl((event.target as HTMLInputElement).value); - setFieldError(null); - setSyncError(null); - }} - onBlur={() => setFieldError(validateGitSourceUrl(url))} - placeholder="https://github.com/acme/tools.git" - className="font-mono text-sm" - aria-invalid={fieldError ? true : undefined} - /> - {fieldError &&

{fieldError}

} -
-
- - setRef((event.target as HTMLInputElement).value)} - placeholder="main" - className="font-mono text-sm" - disabled={syncing} - /> - - - setToken((event.target as HTMLInputElement).value)} - autoComplete="off" - className="font-mono text-sm" - disabled={syncing} - /> - - - ) : ( - -
-
- { - setPath((event.target as HTMLInputElement).value); - setFieldError(null); - setSyncError(null); - }} - onBlur={() => { - if (!path.trim().startsWith("/")) return; - void Effect.runPromiseExit( - listCustomToolDirectoriesEffect({ path: path.trim() }), - ).then((exit) => { - setPathListing(Exit.isSuccess(exit) ? exit.value : null); - }); - }} - placeholder="/Users/me/tools" - className="font-mono text-sm" - aria-invalid={fieldError ? true : undefined} - /> - -
- {pathListing && } - {fieldError &&

{fieldError}

} -
-
- )} - - -
- { - setNameTouched(true); - setName(slugifyCustomToolsAppName((event.target as HTMLInputElement).value)); - setNameError(null); - setSyncError(null); - }} - placeholder="custom-tools" - className="text-sm" - aria-invalid={nameError ? true : undefined} - /> - {nameError &&

{nameError}

} - {slugAlreadyExists && !syncing && !nameError && ( -

- An integration named "{slug}" already exists. -

- )} -
-
-
-
- - {syncError && } - - - - - - {browseOpen && ( - setBrowseOpen(false)} - onSelect={(selectedPath) => { - setPath(selectedPath); - setFieldError(null); - setSyncError(null); - setBrowseOpen(false); - }} - /> - )} -
- ); -} - -function DirectoryBrowserModal(props: { - readonly initialPath?: string; - readonly onSelect: (path: string) => void; - readonly onClose: () => void; -}) { - const [currentPath, setCurrentPath] = useState(props.initialPath); - const [includeHidden, setIncludeHidden] = useState(false); - const [listing, setListing] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let active = true; - setLoading(true); - setError(null); - void Effect.runPromiseExit( - listCustomToolDirectoriesEffect({ path: currentPath, includeHidden }), - ).then((exit) => { - if (!active) return; - setLoading(false); - if (Exit.isFailure(exit)) { - setError("Unable to read this directory."); - return; - } - setListing(exit.value); - setCurrentPath(exit.value.path); - }); - return () => { - active = false; - }; - }, [currentPath, includeHidden]); - - const rows = listing ? directoryBrowserRows(listing) : []; - const selectedPath = listing?.path ?? currentPath ?? ""; - const canSelect = Boolean(selectedPath) && !loading && !error; - const hasToolFiles = (listing?.source.toolFiles.length ?? 0) > 0; - - return ( - (open ? undefined : props.onClose())}> - - - Choose directory - - {selectedPath || "Home"} - - {listing && } - -
- -
- {loading ? ( -
Loading...
- ) : error ? ( -
{error}
- ) : rows.length === 0 ? ( -
- No subdirectories -
- ) : ( -
- {rows.map((row) => ( - - ))} -
- )} -
-
- - - - -
-
- ); -} - -function DirectorySourceStatus(props: { readonly listing: CustomToolsDirectoryListing }) { - const verdict = directorySourceVerdict(props.listing); - if (verdict.type === "valid") { - return ( -

- {verdict.message}:{" "} - {verdict.visibleTools.map((tool, index) => ( - - {index > 0 ? ", " : ""} - {tool} - - ))} - {verdict.moreCount > 0 && ( - <> - {", "} - +{verdict.moreCount} more - - )} -

- ); - } - return

{verdict.message}

; -} diff --git a/packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx b/packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx deleted file mode 100644 index c8bcd2cc34..0000000000 --- a/packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { Effect, Exit } from "effect"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@executor-js/react/components/alert-dialog"; -import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; -import { Button } from "@executor-js/react/components/button"; -import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; - -import { - listCustomToolSourcesEffect, - removeCustomToolSourceEffect, - syncCustomToolSourceEffect, - type AppSourceRecord, -} from "./custom-tools-client"; -import { - sourceFailureLines, - sourcePanelModel, - syncNoticeFromResult, - toolsCountLabel, - type SyncNoticeModel, -} from "./source-panel-model"; - -const integrationsHref = (): string => { - const match = window.location.pathname.match(/^(.*)\/integrations(?:\/.*)?$/); - return `${match?.[1] ?? ""}/integrations`; -}; - -type LoadState = - | { readonly status: "loading" } - | { readonly status: "error"; readonly message: string } - | { readonly status: "ready"; readonly sources: readonly AppSourceRecord[] }; - -export default function CustomToolsAccountsPanel(props: { readonly integrationId: string }) { - const [loadState, setLoadState] = useState({ status: "loading" }); - const [syncingSlug, setSyncingSlug] = useState(null); - const [removingSlug, setRemovingSlug] = useState(null); - const [notice, setNotice] = useState>({}); - const [removeError, setRemoveError] = useState(null); - const sourcesForApp = useCallback( - (sources: readonly AppSourceRecord[]): readonly AppSourceRecord[] => - sources.filter((source) => source.app === props.integrationId), - [props.integrationId], - ); - - const loadSources = async () => { - setLoadState({ status: "loading" }); - const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect()); - if (Exit.isFailure(exit)) { - setLoadState({ status: "error", message: "Failed to load custom tools sources." }); - return; - } - setLoadState({ status: "ready", sources: sourcesForApp(exit.value.sources) }); - }; - - useEffect(() => { - let active = true; - void (async () => { - const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect()); - if (!active) return; - if (Exit.isFailure(exit)) { - setLoadState({ status: "error", message: "Failed to load custom tools sources." }); - return; - } - setLoadState({ status: "ready", sources: sourcesForApp(exit.value.sources) }); - })(); - return () => { - active = false; - }; - }, [props.integrationId, sourcesForApp]); - - const syncSource = async (source: AppSourceRecord) => { - setSyncingSlug(source.slug); - setNotice((current) => ({ ...current, [source.slug]: undefined })); - const beforeTools = - source.status.type === "published" || source.status.type === "up-to-date" - ? source.status.tools - : []; - const exit = await Effect.runPromiseExit(syncCustomToolSourceEffect(source.slug)); - if (Exit.isFailure(exit)) { - setNotice((current) => ({ - ...current, - [source.slug]: { - status: "failed", - message: "Sync failed.", - added: [], - removed: [], - errors: ["Failed to sync custom tools source."], - }, - })); - setSyncingSlug(null); - return; - } - setNotice((current) => ({ - ...current, - [source.slug]: syncNoticeFromResult(exit.value, beforeTools), - })); - await loadSources(); - setSyncingSlug(null); - }; - - const removeSource = async (source: AppSourceRecord) => { - setRemovingSlug(source.slug); - setRemoveError(null); - const exit = await Effect.runPromiseExit(removeCustomToolSourceEffect(source.slug)); - if (Exit.isFailure(exit)) { - setRemoveError("Failed to remove custom tools source."); - setRemovingSlug(null); - return; - } - await loadSources(); - setRemovingSlug(null); - window.location.assign(integrationsHref()); - }; - - return ( -
- {loadState.status === "loading" && ( -
- Loading sources... -
- )} - - {loadState.status === "error" && ( - - Failed to load sources - -
-

{loadState.message}

- -
-
-
- )} - - {loadState.status === "ready" && loadState.sources.length === 0 && ( -
-

No custom tool sources

-

- Add a Git repository to publish tools into the Apps catalog. -

-
- )} - - {loadState.status === "ready" && - loadState.sources.map((source) => ( - void syncSource(source)} - onRemove={() => void removeSource(source)} - /> - ))} -
- ); -} - -function SourceDetail(props: { - readonly source: AppSourceRecord; - readonly notice: SyncNoticeModel | null; - readonly removeError: string | null; - readonly syncing: boolean; - readonly removing: boolean; - readonly onSync: () => void; - readonly onRemove: () => void; -}) { - const { source, notice } = props; - const model = sourcePanelModel(source); - const failureLines = sourceFailureLines(source); - const noticeHasDetails = - notice !== null && - (notice.added.length > 0 || - notice.removed.length > 0 || - notice.errors.length > 0 || - notice.sourceRef !== undefined); - return ( -
-
-
-

{model.title}

-

{model.source}

-
-
- - - - - - - - Remove {source.app}? - - This removes {toolsCountLabel(model.tools.length)} from the catalog. The source is - untouched and can be added again later. - - - - Cancel - - {props.removing ? "Removing..." : "Remove source"} - - - - -
-
- -
-
- - - -
- - {failureLines.length > 0 && ( - - Last sync failed - -
- {failureLines.map((line) => ( -

{line}

- ))} -
-
-
- )} - - {notice && ( - - {notice.message} - {noticeHasDetails && ( - -
- {notice.added.length > 0 &&

Added: {notice.added.join(", ")}

} - {notice.removed.length > 0 &&

Removed: {notice.removed.join(", ")}

} - {notice.errors.map((error) => ( -

{error}

- ))} - {notice.sourceRef && ( -

- Commit {notice.sourceRef} -

- )} -
-
- )} -
- )} - - {props.removeError && } -
-
- ); -} - -function Meta(props: { readonly label: string; readonly value: string; readonly mono?: boolean }) { - return ( -
-

- {props.label} -

-

- {props.value} -

-
- ); -} diff --git a/packages/plugins/apps/src/react/custom-tools-client.ts b/packages/plugins/apps/src/react/custom-tools-client.ts deleted file mode 100644 index e99e403f59..0000000000 --- a/packages/plugins/apps/src/react/custom-tools-client.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { Data, Effect, Schema } from "effect"; -import { - EXECUTOR_ORG_HEADER, - getActiveOrgSlug, - getExecutorApiBaseUrl, - getExecutorServerAuthorizationHeader, -} from "@executor-js/react/api/server-connection"; - -export const CUSTOM_TOOLS_PLUGIN_KEY = "apps"; -export const CUSTOM_TOOLS_LABEL = "Custom tools"; - -export type AppSourceKind = "git" | "local-directory"; - -export interface SyncDiagnostic { - readonly stage: "source" | "discover" | "bundle" | "collect" | "project"; - readonly message: string; - readonly diagnostics?: readonly { readonly path: string; readonly message: string }[]; -} - -export type SourceStatus = - | { readonly type: "pending" } - | { - readonly type: "published" | "up-to-date"; - readonly at: number; - readonly tools: readonly string[]; - } - | { readonly type: "failed"; readonly at: number; readonly errors: readonly SyncDiagnostic[] }; - -export type AppSourceRecord = - | { - readonly slug: string; - readonly app: string; - readonly kind: "git"; - readonly config: { - readonly kind: "git"; - readonly url: string; - readonly ref?: string; - readonly tokenProvider?: string; - readonly tokenItemId?: string; - }; - readonly sourceRef?: string; - readonly description?: string; - readonly status: SourceStatus; - readonly updatedAt: number; - } - | { - readonly slug: string; - readonly app: string; - readonly kind: "local-directory"; - readonly config: { readonly kind: "local-directory"; readonly path: string }; - readonly sourceRef?: string; - readonly description?: string; - readonly status: SourceStatus; - readonly updatedAt: number; - }; - -export interface SourcesListResponse { - readonly sources: readonly AppSourceRecord[]; -} - -export interface SourceDetailResponse { - readonly source: AppSourceRecord | null; -} - -export type CreateSourceRequest = - | { - readonly kind: "git"; - readonly slug?: string; - readonly app?: string; - readonly url: string; - readonly ref?: string; - readonly token?: string; - } - | { - readonly kind: "local-directory"; - readonly slug?: string; - readonly app?: string; - readonly path: string; - }; - -export interface CreateSourceResponse { - readonly source: AppSourceRecord; -} - -export type SyncSourceResult = - | { - readonly status: "published" | "up-to-date"; - readonly sourceRef: string; - readonly tools: readonly string[]; - readonly errors?: undefined; - } - | { - readonly status: "failed"; - readonly sourceRef?: string; - readonly tools: readonly string[]; - readonly errors: readonly SyncDiagnostic[]; - }; - -export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; - -const customToolsFetch: CustomToolsFetch = (input, init = {}) => { - const headers = new Headers(init.headers); - const authorization = getExecutorServerAuthorizationHeader(); - if (authorization && !headers.has("authorization")) { - headers.set("authorization", authorization); - } - const orgSlug = getActiveOrgSlug(); - if (orgSlug && !headers.has(EXECUTOR_ORG_HEADER)) { - headers.set(EXECUTOR_ORG_HEADER, orgSlug); - } - const url = - typeof input === "string" && input.startsWith("/") - ? new URL(input, getExecutorApiBaseUrl()).toString() - : input; - return fetch(url, { ...init, headers }); -}; - -export interface CustomToolsDirectoryEntry { - readonly name: string; - readonly path: string; - readonly isSymlink: boolean; - readonly hasTools: boolean; -} - -export interface CustomToolsDirectorySourceShape { - readonly toolFiles: readonly string[]; - readonly skipped: readonly string[]; - readonly hasPackageJson: boolean; -} - -export interface CustomToolsDirectoryListing { - readonly path: string; - readonly parent: string | null; - readonly dirs: readonly CustomToolsDirectoryEntry[]; - readonly source: CustomToolsDirectorySourceShape; -} - -export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{ - readonly message: string; -}> {} - -export const slugifyCustomToolsAppName = (value: string): string => - value - .trim() - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80); - -export const validateCustomToolsAppSlug = (value: string): string | null => { - if (!value.trim()) return "Enter a source name."; - if (value !== slugifyCustomToolsAppName(value)) { - return "Use lowercase letters, numbers, and hyphens."; - } - return null; -}; - -export const parseGitSourceUrl = ( - raw: string, -): - | { readonly ok: true; readonly url: string; readonly name: string } - | { readonly ok: false; readonly message: string } => { - const value = raw.trim(); - if (!value) return { ok: false, message: "Enter a Git repository URL." }; - if (!URL.canParse(value)) return { ok: false, message: "Enter an https Git repository URL." }; - const parsed = new URL(value); - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { - return { ok: false, message: "Git repository URLs must use http or https." }; - } - if (parsed.username || parsed.password) { - return { ok: false, message: "Do not include credentials in the URL. Use the token field." }; - } - const segments = parsed.pathname.split("/").filter(Boolean); - const last = segments.at(-1)?.replace(/\.git$/, "") ?? ""; - if (!last) return { ok: false, message: "Enter a repository URL with a path." }; - parsed.hash = ""; - return { ok: true, url: parsed.toString(), name: last }; -}; - -export const suggestCustomToolsAppName = (urlOrPath: string): string => { - const parsed = parseGitSourceUrl(urlOrPath); - if (parsed.ok) return parsed.name; - const pathParts = urlOrPath.split("/").filter(Boolean); - return pathParts.at(-1) ?? ""; -}; - -export const validateGitSourceUrl = (url: string): string | null => { - const parsed = parseGitSourceUrl(url); - return parsed.ok ? null : parsed.message; -}; - -const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); -const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); - -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === "object" && !Array.isArray(value); - -const responseErrorMessage = (body: unknown, fallback: string): string => - isRecord(body) && typeof body.message === "string" - ? body.message - : isRecord(body) && typeof body.error === "string" - ? body.error - : fallback; - -const parseJsonResponseEffect = ( - response: Response, - fallback: string, -): Effect.Effect => - Effect.gen(function* () { - const text = yield* Effect.tryPromise({ - try: () => response.text(), - catch: () => new CustomToolsClientError({ message: fallback }), - }); - const body = - text.length > 0 - ? yield* decodeJsonText(text).pipe( - Effect.mapError(() => new CustomToolsClientError({ message: fallback })), - ) - : null; - if (!response.ok) { - return yield* new CustomToolsClientError({ - message: responseErrorMessage(body, fallback), - }); - } - return body as A; - }); - -export const listCustomToolSourcesEffect = ( - fetchImpl: CustomToolsFetch = customToolsFetch, -): Effect.Effect => - Effect.tryPromise({ - try: () => fetchImpl("/api/apps/sources", { credentials: "same-origin" }), - catch: () => new CustomToolsClientError({ message: "Failed to load custom tools sources." }), - }).pipe( - Effect.flatMap((response) => - parseJsonResponseEffect( - response, - "Failed to load custom tools sources.", - ), - ), - ); - -export const listCustomToolSources = ( - fetchImpl: CustomToolsFetch = customToolsFetch, -): Promise => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl)); - -export const getCustomToolSourceEffect = ( - slug: string, - fetchImpl: CustomToolsFetch = customToolsFetch, -): Effect.Effect => - Effect.tryPromise({ - try: () => - fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}`, { - credentials: "same-origin", - }), - catch: () => new CustomToolsClientError({ message: "Failed to load custom tools source." }), - }).pipe( - Effect.flatMap((response) => - parseJsonResponseEffect( - response, - "Failed to load custom tools source.", - ), - ), - ); - -export const createCustomToolSourceEffect = ( - input: CreateSourceRequest, - fetchImpl: CustomToolsFetch = customToolsFetch, -): Effect.Effect => - Effect.tryPromise({ - try: () => - fetchImpl("/api/apps/sources", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: encodeJsonText(input), - }), - catch: () => new CustomToolsClientError({ message: "Failed to create custom tools source." }), - }).pipe( - Effect.flatMap((response) => - parseJsonResponseEffect( - response, - "Failed to create custom tools source.", - ), - ), - ); - -export const listCustomToolDirectoriesEffect = ( - input: { readonly path?: string; readonly includeHidden?: boolean } = {}, - fetchImpl: CustomToolsFetch = customToolsFetch, -): Effect.Effect => - Effect.tryPromise({ - try: () => { - const query = new URLSearchParams(); - if (input.path) query.set("path", input.path); - if (input.includeHidden) query.set("includeHidden", "true"); - const suffix = query.size > 0 ? `?${query.toString()}` : ""; - return fetchImpl(`/api/apps/fs/dirs${suffix}`, { credentials: "same-origin" }); - }, - catch: () => new CustomToolsClientError({ message: "Failed to browse directories." }), - }).pipe( - Effect.flatMap((response) => - parseJsonResponseEffect( - response, - "Failed to browse directories.", - ), - ), - ); - -export const syncCustomToolSourceEffect = ( - slug: string, - fetchImpl: CustomToolsFetch = customToolsFetch, -): Effect.Effect => - Effect.tryPromise({ - try: () => - fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}/sync`, { - method: "POST", - credentials: "same-origin", - }), - catch: () => new CustomToolsClientError({ message: "Failed to sync custom tools source." }), - }).pipe( - Effect.flatMap((response) => - parseJsonResponseEffect(response, "Failed to sync custom tools source."), - ), - ); - -export const removeCustomToolSourceEffect = ( - slug: string, - fetchImpl: CustomToolsFetch = customToolsFetch, -): Effect.Effect<{ readonly removed: boolean }, CustomToolsClientError> => - Effect.tryPromise({ - try: () => - fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}`, { - method: "DELETE", - credentials: "same-origin", - }), - catch: () => new CustomToolsClientError({ message: "Failed to remove custom tools source." }), - }).pipe( - Effect.flatMap((response) => - parseJsonResponseEffect<{ readonly removed: boolean }>( - response, - "Failed to remove custom tools source.", - ), - ), - ); - -export const syncStatusLabel = (result: SyncSourceResult): string => { - if (result.status === "published") return `Published ${result.tools.length} tools.`; - if (result.status === "up-to-date") return "Already up to date."; - return "Sync failed."; -}; - -export const formatDiagnostics = ( - errors: readonly SyncDiagnostic[] | undefined, -): readonly string[] => - (errors ?? []).flatMap((entry) => { - const head = `${entry.stage}: ${entry.message}`; - const details = entry.diagnostics?.map((d) => `${d.path}: ${d.message}`) ?? []; - return details.length > 0 ? [head, ...details] : [head]; - }); - -export const formatSyncErrors = (result: SyncSourceResult): readonly string[] => - result.status === "failed" ? formatDiagnostics(result.errors) : []; - -export const toolDiff = ( - before: readonly string[], - after: readonly string[], -): { readonly added: readonly string[]; readonly removed: readonly string[] } => { - const beforeSet = new Set(before); - const afterSet = new Set(after); - return { - added: after.filter((tool) => !beforeSet.has(tool)), - removed: before.filter((tool) => !afterSet.has(tool)), - }; -}; diff --git a/packages/plugins/apps/src/react/custom-tools-client.web.test.ts b/packages/plugins/apps/src/react/custom-tools-client.web.test.ts deleted file mode 100644 index 0e52b17a45..0000000000 --- a/packages/plugins/apps/src/react/custom-tools-client.web.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; - -import appsClientPlugin, { - CUSTOM_TOOLS_LABEL, - CUSTOM_TOOLS_PLUGIN_KEY, - createCustomToolSourceEffect, - formatSyncErrors, - listCustomToolDirectoriesEffect, - listCustomToolSources, - parseGitSourceUrl, - removeCustomToolSourceEffect, - slugifyCustomToolsAppName, - suggestCustomToolsAppName, - syncCustomToolSourceEffect, - syncStatusLabel, - type AppSourceRecord, - type CustomToolsFetch, -} from "./plugin-client"; -import { - directorySourceVerdict, - sourcePanelModel, - syncNoticeFromResult, -} from "./source-panel-model"; -import { directoryBrowserRows } from "./source-panel-model"; - -const jsonResponse = (body: unknown, init?: ResponseInit): Response => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - ...init, - }); - -const demoSource: AppSourceRecord = { - slug: "demo-tools", - app: "demo-tools", - kind: "git", - config: { - kind: "git", - url: "https://gitlab.com/acme/demo-tools.git", - ref: "main", - tokenProvider: "default", - tokenItemId: "apps/source-tokens/demo-tools", - }, - sourceRef: "abc123def456", - description: "Demo tools", - status: { - type: "published", - at: Date.parse("2026-07-06T12:00:00.000Z"), - tools: ["repo-summary", "stale-issues"], - }, - updatedAt: Date.parse("2026-07-06T12:00:00.000Z"), -}; - -describe("custom tools console client", () => { - it("registers the Apps integration plugin", () => { - const plugin = appsClientPlugin({ sourceKinds: ["git"] }); - - expect(plugin.id).toBe(CUSTOM_TOOLS_PLUGIN_KEY); - expect(plugin.integrationPlugin?.label).toBe(CUSTOM_TOOLS_LABEL); - }); - - it("validates and suggests generic https Git repository URLs", () => { - expect(parseGitSourceUrl("https://github.com/UsefulSoftwareCo/executor.git")).toMatchObject({ - ok: true, - name: "executor", - url: "https://github.com/UsefulSoftwareCo/executor.git", - }); - expect(parseGitSourceUrl("https://gitlab.com/acme/tools")).toMatchObject({ - ok: true, - name: "tools", - }); - expect(parseGitSourceUrl("ssh://gitlab.com/acme/tools")).toMatchObject({ - ok: false, - message: "Git repository URLs must use http or https.", - }); - expect(parseGitSourceUrl("https://x:secret@gitlab.com/acme/tools")).toMatchObject({ - ok: false, - message: "Do not include credentials in the URL. Use the token field.", - }); - expect(suggestCustomToolsAppName("https://gitlab.com/acme/tools.git")).toBe("tools"); - expect(slugifyCustomToolsAppName("Acme Tools!")).toBe("acme-tools"); - }); - - it("uses the new source routes and keeps token-bearing input out of read responses", async () => { - const calls: Array<{ readonly url: string; readonly init?: RequestInit }> = []; - let createBody = ""; - const fetchImpl: CustomToolsFetch = async (input, init) => { - const url = String(input); - calls.push({ url, init }); - if (url === "/api/apps/sources" && init?.method === "POST") { - createBody = String(init.body); - return jsonResponse({ source: demoSource }); - } - if (url === "/api/apps/sources") { - return jsonResponse({ sources: [demoSource] }); - } - if (url === "/api/apps/sources/demo-tools/sync") { - return jsonResponse({ - status: "up-to-date", - sourceRef: "abc123def456", - tools: ["repo-summary", "stale-issues"], - }); - } - if (url === "/api/apps/sources/demo-tools") { - return jsonResponse({ removed: true }); - } - if (url === "/api/apps/fs/dirs?path=%2FUsers%2Fada%2Ftools&includeHidden=true") { - return jsonResponse({ - path: "/Users/ada/tools", - parent: "/Users/ada", - dirs: [ - { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: true }, - ], - source: { toolFiles: ["greet.ts"], skipped: [], hasPackageJson: true }, - }); - } - return jsonResponse({ error: "not found" }, { status: 404 }); - }; - - const created = await Effect.runPromise( - createCustomToolSourceEffect( - { - kind: "git", - slug: "demo-tools", - app: "demo-tools", - url: "https://gitlab.com/acme/demo-tools.git", - token: "ghp_secret", - }, - fetchImpl, - ), - ); - const listed = await listCustomToolSources(fetchImpl); - const synced = await Effect.runPromise(syncCustomToolSourceEffect("demo-tools", fetchImpl)); - const removed = await Effect.runPromise(removeCustomToolSourceEffect("demo-tools", fetchImpl)); - const dirs = await Effect.runPromise( - listCustomToolDirectoriesEffect({ path: "/Users/ada/tools", includeHidden: true }, fetchImpl), - ); - - expect(created.source.slug).toBe("demo-tools"); - expect(createBody).toContain("ghp_secret"); - expect(JSON.stringify(listed)).not.toContain("ghp_secret"); - expect(syncStatusLabel(synced)).toBe("Already up to date."); - expect(removed).toEqual({ removed: true }); - expect(dirs.dirs).toEqual([ - { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: true }, - ]); - expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([ - "POST /api/apps/sources", - "GET /api/apps/sources", - "POST /api/apps/sources/demo-tools/sync", - "DELETE /api/apps/sources/demo-tools", - "GET /api/apps/fs/dirs?path=%2FUsers%2Fada%2Ftools&includeHidden=true", - ]); - }); - - it("models the source panel and sync notice", () => { - const panel = sourcePanelModel(demoSource, { - now: Date.parse("2026-07-06T12:05:00.000Z"), - }); - const notice = syncNoticeFromResult( - { - status: "published", - sourceRef: "def456abc123", - tools: ["repo-summary", "stale-issues", "extra-tool"], - }, - demoSource.status.type === "published" ? demoSource.status.tools : [], - ); - - expect(panel.title).toBe("demo-tools"); - expect(panel.source).toBe("Git repository: https://gitlab.com/acme/demo-tools.git @ main"); - expect(panel.status).toBe("Published 5m ago"); - expect(panel.sourceRef).toBe("abc123def456"); - expect(panel.tools).toEqual(["repo-summary", "stale-issues"]); - expect(notice.message).toBe("Published 3 tools."); - expect(notice.added).toEqual(["extra-tool"]); - expect(notice.sourceRef).toBe("def456abc123"); - }); - - it("models the directory browser rows", () => { - expect( - directoryBrowserRows({ - path: "/Users/ada/tools", - parent: "/Users/ada", - dirs: [ - { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: false }, - { name: "linked", path: "/Users/ada/tools/linked", isSymlink: true, hasTools: false }, - ], - source: { toolFiles: [], skipped: [], hasPackageJson: false }, - }), - ).toEqual([ - { kind: "parent", name: "..", path: "/Users/ada" }, - { - kind: "dir", - name: "alpha", - path: "/Users/ada/tools/alpha", - isSymlink: false, - hasTools: false, - }, - { - kind: "dir", - name: "linked", - path: "/Users/ada/tools/linked", - isSymlink: true, - hasTools: false, - }, - ]); - }); - - it("models directory source verdicts", () => { - expect( - directorySourceVerdict({ - path: "/repo", - parent: null, - dirs: [{ name: "tools", path: "/repo/tools", isSymlink: false, hasTools: false }], - source: { toolFiles: [], skipped: [], hasPackageJson: false }, - }), - ).toEqual({ - type: "empty-tools", - message: "tools/ has no tool files (tools/.ts)", - }); - expect( - directorySourceVerdict({ - path: "/repo", - parent: null, - dirs: [], - source: { toolFiles: [], skipped: [], hasPackageJson: false }, - }), - ).toEqual({ - type: "missing-tools", - message: "No tools/ folder. Pick a folder containing tools/.ts files.", - }); - expect( - directorySourceVerdict({ - path: "/repo", - parent: null, - dirs: [], - source: { - toolFiles: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"], - skipped: [], - hasPackageJson: true, - }, - }), - ).toEqual({ - type: "valid", - message: "6 tools found", - visibleTools: ["a", "b", "c", "d", "e"], - moreCount: 1, - }); - }); - - it("formats failed sync diagnostics by stage", () => { - const errors = formatSyncErrors({ - status: "failed", - tools: [], - errors: [ - { - stage: "collect", - message: "schema export failed", - diagnostics: [{ path: "tools/x.ts", message: "unsupported schema" }], - }, - ], - }); - - expect(errors).toEqual(["collect: schema export failed", "tools/x.ts: unsupported schema"]); - }); -}); diff --git a/packages/plugins/apps/src/react/index.ts b/packages/plugins/apps/src/react/index.ts deleted file mode 100644 index 72d1b63394..0000000000 --- a/packages/plugins/apps/src/react/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./plugin-client"; -export * from "./custom-tools-client"; diff --git a/packages/plugins/apps/src/react/plugin-client.tsx b/packages/plugins/apps/src/react/plugin-client.tsx deleted file mode 100644 index 2ca5e6eb44..0000000000 --- a/packages/plugins/apps/src/react/plugin-client.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { defineClientPlugin } from "@executor-js/sdk/client"; - -import { makeAppsIntegrationPlugin } from "./source-plugin"; -import type { AppSourceKind } from "./custom-tools-client"; - -export { makeAppsIntegrationPlugin } from "./source-plugin"; -export * from "./custom-tools-client"; - -export default function appsClientPlugin(config?: { - readonly sourceKinds?: readonly AppSourceKind[]; -}) { - return defineClientPlugin({ - id: "apps", - integrationPlugin: makeAppsIntegrationPlugin(config), - }); -} diff --git a/packages/plugins/apps/src/react/source-panel-model.ts b/packages/plugins/apps/src/react/source-panel-model.ts deleted file mode 100644 index 865778aac0..0000000000 --- a/packages/plugins/apps/src/react/source-panel-model.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { - AppSourceRecord, - CustomToolsDirectoryListing, - SyncSourceResult, -} from "./custom-tools-client"; -import { - formatDiagnostics, - formatSyncErrors, - syncStatusLabel, - toolDiff, -} from "./custom-tools-client"; - -export interface SourcePanelModel { - readonly title: string; - readonly source: string; - readonly sourceRef: string; - readonly status: string; - readonly tools: readonly string[]; -} - -export interface SyncNoticeModel { - readonly status: SyncSourceResult["status"]; - readonly message: string; - readonly added: readonly string[]; - readonly removed: readonly string[]; - readonly errors: readonly string[]; - readonly sourceRef?: string; -} - -export type DirectoryBrowserRow = - | { readonly kind: "parent"; readonly name: ".."; readonly path: string } - | { - readonly kind: "dir"; - readonly name: string; - readonly path: string; - readonly isSymlink: boolean; - readonly hasTools: boolean; - }; - -export type DirectorySourceVerdict = - | { - readonly type: "valid"; - readonly message: string; - readonly visibleTools: readonly string[]; - readonly moreCount: number; - } - | { readonly type: "empty-tools"; readonly message: string } - | { readonly type: "missing-tools"; readonly message: string }; - -const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced"); - -export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => { - const diffMs = Math.max(0, now - timestamp); - const min = Math.floor(diffMs / 60_000); - if (min < 1) return "just now"; - if (min < 60) return `${min}m ago`; - const hrs = Math.floor(min / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - if (days < 7) return `${days}d ago`; - return new Date(timestamp).toLocaleDateString(); -}; - -export const toolsCountLabel = (count: number): string => - `${count} ${count === 1 ? "tool" : "tools"}`; - -export const sourcePanelModel = ( - source: AppSourceRecord, - options?: { readonly now?: number }, -): SourcePanelModel => { - const tools = - source.status.type === "published" || source.status.type === "up-to-date" - ? source.status.tools - : []; - const sourceLabel = source.config.kind === "git" ? source.config.url : source.config.path; - const pin = source.config.kind === "git" && source.config.ref ? ` @ ${source.config.ref}` : ""; - const status = - source.status.type === "pending" - ? "Pending sync" - : source.status.type === "failed" - ? `Failed ${formatRelativeSyncTime(source.status.at, options?.now)}` - : `${source.status.type === "published" ? "Published" : "Up to date"} ${formatRelativeSyncTime( - source.status.at, - options?.now, - )}`; - return { - title: source.app, - source: `${source.kind === "git" ? "Git repository" : "Local directory"}: ${sourceLabel}${pin}`, - sourceRef: shortRef(source.sourceRef), - status, - tools, - }; -}; - -export const sourceFailureLines = (source: AppSourceRecord): readonly string[] => - source.status.type === "failed" ? formatDiagnostics(source.status.errors) : []; - -export const syncNoticeFromResult = ( - result: SyncSourceResult, - beforeTools: readonly string[], -): SyncNoticeModel => { - const diff = - result.status === "failed" ? { added: [], removed: [] } : toolDiff(beforeTools, result.tools); - return { - status: result.status, - message: syncStatusLabel(result), - added: diff.added, - removed: diff.removed, - errors: formatSyncErrors(result), - ...(result.sourceRef ? { sourceRef: result.sourceRef.slice(0, 12) } : {}), - }; -}; - -export const directoryBrowserRows = ( - listing: CustomToolsDirectoryListing, -): readonly DirectoryBrowserRow[] => [ - ...(listing.parent - ? [{ kind: "parent" as const, name: ".." as const, path: listing.parent }] - : []), - ...listing.dirs.map((dir) => ({ - kind: "dir" as const, - name: dir.name, - path: dir.path, - isSymlink: dir.isSymlink, - hasTools: dir.hasTools, - })), -]; - -const toolNameFromFile = (file: string): string => file.replace(/\.[^.]+$/, ""); - -export const directorySourceVerdict = ( - listing: CustomToolsDirectoryListing, -): DirectorySourceVerdict => { - const source = listing.source; - if (source.toolFiles.length > 0) { - const toolNames = source.toolFiles.map(toolNameFromFile); - const visibleTools = toolNames.slice(0, 5); - const moreCount = Math.max(0, toolNames.length - visibleTools.length); - return { - type: "valid", - message: `${toolsCountLabel(source.toolFiles.length)} found`, - visibleTools, - moreCount, - }; - } - if (listing.dirs.some((dir) => dir.name === "tools" && !dir.isSymlink)) { - return { - type: "empty-tools", - message: "tools/ has no tool files (tools/.ts)", - }; - } - return { - type: "missing-tools", - message: "No tools/ folder. Pick a folder containing tools/.ts files.", - }; -}; diff --git a/packages/plugins/apps/src/react/source-plugin.tsx b/packages/plugins/apps/src/react/source-plugin.tsx deleted file mode 100644 index 412a2a5043..0000000000 --- a/packages/plugins/apps/src/react/source-plugin.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { lazy } from "react"; -import type { IntegrationPlugin } from "@executor-js/sdk/client"; - -import { - CUSTOM_TOOLS_LABEL, - CUSTOM_TOOLS_PLUGIN_KEY, - type AppSourceKind, -} from "./custom-tools-client"; - -const importAccounts = () => import("./CustomToolsAccountsPanel"); - -export const makeAppsIntegrationPlugin = (config?: { - readonly sourceKinds?: readonly AppSourceKind[]; -}): IntegrationPlugin => { - const sourceKinds = config?.sourceKinds ?? ["git"]; - const Add = lazy(async () => { - const mod = await import("./AddCustomToolsSource"); - const AddCustomToolsSource = mod.default; - return { - default: (props: Parameters[0]) => ( - - ), - }; - }); - const Accounts = lazy(importAccounts); - return { - key: CUSTOM_TOOLS_PLUGIN_KEY, - label: CUSTOM_TOOLS_LABEL, - add: Add, - accounts: Accounts, - preload: () => { - void import("./AddCustomToolsSource"); - void importAccounts(); - }, - }; -}; diff --git a/packages/plugins/apps/src/selfhost.ts b/packages/plugins/apps/src/selfhost.ts deleted file mode 100644 index bf38abc89d..0000000000 --- a/packages/plugins/apps/src/selfhost.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { makeWorkerdAppToolExecutor } from "./executor/workerd-app-tool-executor"; -export { makeWorkerBundlerBackend } from "./pipeline/worker-bundler"; -export { WORKER_BUNDLER_VERSION } from "./pipeline/worker-bundler-version"; diff --git a/packages/plugins/apps/src/source/app-source.test.ts b/packages/plugins/apps/src/source/app-source.test.ts deleted file mode 100644 index e83ff78a1a..0000000000 --- a/packages/plugins/apps/src/source/app-source.test.ts +++ /dev/null @@ -1,579 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: tests use fixture server cleanup and hard-fail setup errors */ -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; -import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; -import { deflateSync } from "node:zlib"; - -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; - -import { FLUSH, parseInfoRefs, pktLine, resolveWant } from "../git-client/pktline"; -import { handFetch } from "../git-client/hand"; -import { parsePack, walkTree } from "../git-client/packfile"; -import { authForHost, checkRefs, uploadPack } from "../git-client/transport"; -import { PUBLISH_LIMITS } from "../pipeline/publish"; -import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "./git-source"; -import { fetchLocalDirectoryAppSource, listLocalDirectoryDirs } from "./local-directory-source"; - -const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); -const textDecoder = new TextDecoder(); -const textEncoder = new TextEncoder(); - -const concat = (parts: readonly Uint8Array[]): Uint8Array => { - const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); - let offset = 0; - for (const part of parts) { - out.set(part, offset); - offset += part.length; - } - return out; -}; - -const sideBand = (bytes: Uint8Array): Uint8Array => { - const chunks: Uint8Array[] = [pktLine("NAK\n")]; - for (let offset = 0; offset < bytes.length; offset += 60_000) { - const chunk = bytes.subarray(offset, offset + 60_000); - const payload = new Uint8Array(chunk.length + 1); - payload[0] = 1; - payload.set(chunk, 1); - chunks.push(pktLine(payload)); - } - chunks.push(FLUSH); - return concat(chunks); -}; - -const advertisement = (sha: string): Uint8Array => - concat([ - pktLine("# service=git-upload-pack\n"), - FLUSH, - pktLine( - `${sha} HEAD\0symref=HEAD:refs/heads/main multi_ack side-band-64k thin-pack ofs-delta\n`, - ), - pktLine(`${sha} refs/heads/main\n`), - pktLine(`${sha} refs/tags/v1\n`), - pktLine(`${sha} refs/tags/v1^{}\n`), - FLUSH, - ]); - -const gitObjectId = async (type: string, data: Uint8Array): Promise => { - const header = textEncoder.encode(`${type} ${data.length}\0`); - const full = concat([header, data]); - const digest = new Uint8Array(await crypto.subtle.digest("SHA-1", full as BufferSource)); - return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -}; - -const hexBytes = (hex: string): Uint8Array => { - const out = new Uint8Array(hex.length / 2); - for (let index = 0; index < out.length; index += 1) { - out[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); - } - return out; -}; - -const packObjectHeader = (type: number, size: number): Uint8Array => { - const bytes: number[] = []; - let first = (type << 4) | (size & 0x0f); - size = Math.floor(size / 16); - if (size > 0) first |= 0x80; - bytes.push(first); - while (size > 0) { - let next = size & 0x7f; - size = Math.floor(size / 128); - if (size > 0) next |= 0x80; - bytes.push(next); - } - return Uint8Array.from(bytes); -}; - -const packFile = (objects: readonly Uint8Array[]): Uint8Array => { - const header = new Uint8Array(12); - header.set(textEncoder.encode("PACK"), 0); - const view = new DataView(header.buffer); - view.setUint32(4, 2); - view.setUint32(8, objects.length); - return concat([header, ...objects, new Uint8Array(20)]); -}; - -const packedObject = (type: number, data: Uint8Array): Uint8Array => - concat([packObjectHeader(type, data.length), deflateSync(data)]); - -const deltaVarint = (value: number): Uint8Array => { - const bytes: number[] = []; - do { - let next = value & 0x7f; - value = Math.floor(value / 128); - if (value > 0) next |= 0x80; - bytes.push(next); - } while (value > 0); - return Uint8Array.from(bytes); -}; - -const readFixture = async () => { - const dir = join(import.meta.dirname, "fixtures"); - const [shas, pack1, pack2] = await Promise.all([ - readFile(join(dir, "git-fixture-shas.txt"), "utf8"), - readFile(join(dir, "git-fixture-v1.pack")), - readFile(join(dir, "git-fixture-v2.pack")), - ]); - const [sha1, sha2] = shas.trim().split("\n"); - return { sha1: sha1!, sha2: sha2!, pack1, pack2 }; -}; - -const fixtureServer = async () => { - const fixture = await readFixture(); - let current = { sha: fixture.sha1, pack: new Uint8Array(fixture.pack1) }; - let packRequests = 0; - const server = createServer((request: IncomingMessage, response: ServerResponse) => { - if (request.url === "/repo.git/info/refs?service=git-upload-pack") { - response.writeHead(200, { - "content-type": "application/x-git-upload-pack-advertisement", - }); - response.end(advertisement(current.sha)); - return; - } - if (request.url === "/repo.git/git-upload-pack" && request.method === "POST") { - packRequests += 1; - response.writeHead(200, { "content-type": "application/x-git-upload-pack-result" }); - response.end(sideBand(current.pack)); - return; - } - response.writeHead(404, { "content-type": "text/plain" }); - response.end("not found"); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("fixture server did not bind"); - return { - url: `https://example.test/repo`, - fetch: ((rawUrl: string, init?: RequestInit) => { - const incoming = new URL(rawUrl); - const local = new URL( - `http://127.0.0.1:${address.port}${incoming.pathname}${incoming.search}`, - ); - return fetch(local, init); - }) as typeof fetch, - advance: () => { - current = { sha: fixture.sha2, pack: new Uint8Array(fixture.pack2) }; - }, - packRequests: () => packRequests, - close: () => new Promise((resolve) => server.close(() => resolve())), - }; -}; - -const liveOrSkip = async (url: string): Promise => { - try { - const response = await fetch(url, { method: "HEAD" }); - return response.ok || response.status < 500; - } catch { - return false; - } -}; - -type FetchHandler = ( - input: Parameters[0], - init?: Parameters[1], -) => ReturnType; - -const testFetch = (handler: FetchHandler): typeof fetch => - Object.assign(handler, { preconnect: () => undefined }) as typeof fetch; - -describe("git app sources", () => { - it("parses github and gitlab advertisements", () => { - const github = parseInfoRefs(advertisement("1111111111111111111111111111111111111111")); - expect(github.headTarget).toBe("refs/heads/main"); - expect(github.refs.get("refs/tags/v1^{}")).toBe("1111111111111111111111111111111111111111"); - expect(resolveWant(github).resolvedRef).toBe("refs/heads/main"); - - const gitlab = parseInfoRefs( - concat([ - pktLine("# service=git-upload-pack\n"), - FLUSH, - pktLine( - "2222222222222222222222222222222222222222 HEAD\0symref=HEAD:refs/heads/master multi_ack side-band-64k thin-pack ofs-delta\n", - ), - pktLine("2222222222222222222222222222222222222222 refs/heads/master\n"), - pktLine("3333333333333333333333333333333333333333 refs/tags/v1\n"), - pktLine("2222222222222222222222222222222222222222 refs/tags/v1^{}\n"), - FLUSH, - ]), - ); - expect(resolveWant(gitlab).resolvedRef).toBe("refs/heads/master"); - expect(resolveWant(gitlab, "v1").sha).toBe("3333333333333333333333333333333333333333"); - }); - - it("uses host-specific auth recipes", () => { - expect(authForHost("github.com", "t").authorization).toBe(`Basic ${btoa("x-access-token:t")}`); - expect(authForHost("gitlab.com", "t").authorization).toBe(`Basic ${btoa("oauth2:t")}`); - expect(authForHost("bitbucket.org", "t").authorization).toBe(`Basic ${btoa("x-token-auth:t")}`); - expect(authForHost("codeberg.org", "t").authorization).toBe(`Basic ${btoa("git:t")}`); - }); - - it("rejects truncated and malformed pkt-line responses", () => { - expect(() => - parseInfoRefs(concat([pktLine("# service=git-upload-pack\n"), Uint8Array.from([48])])), - ).toThrow(/truncated pkt-line/); - expect(() => parseInfoRefs(textEncoder.encode("0002"))).toThrow(/bad pkt-line length/); - }); - - it("rejects truncated upload-pack side-band responses", async () => { - await expect( - uploadPack("https://example.test/repo", "1".repeat(40), { - fetchImpl: testFetch(async () => new Response(textEncoder.encode("0009\u0001PAC"))), - }), - ).rejects.toThrow(/truncated upload-pack response/); - }); - - it.effect("rejects private git hosts under cloud posture", () => - Effect.gen(function* () { - for (const url of [ - "http://localhost/repo.git", - "https://localhost/repo.git", - "https://127.0.0.1/repo.git", - "https://[::ffff:127.0.0.1]/repo.git", - "https://2130706433/repo.git", - "https://0177.0.0.1/repo.git", - "https://0x7f.0.0.1/repo.git", - "https://10.1.2.3/repo.git", - "https://169.254.1.1/repo.git", - ]) { - const exit = yield* Effect.exit(parseGitSourceUrl(url)); - expect(Exit.isFailure(exit)).toBe(true); - } - expect( - yield* parseGitSourceUrl("http://127.0.0.1:9999/repo.git", { - allowPrivateHosts: true, - }), - ).toBeInstanceOf(URL); - expect(yield* parseGitSourceUrl("https://gitlab.com/acme/tools.git")).toBeInstanceOf(URL); - }), - ); - - it("rejects redirects to private or non-https git hosts", async () => { - const exit = await Effect.runPromiseExit( - checkGitAppSourceRefs({ - url: "https://example.test/repo", - fetch: testFetch( - async () => - new Response(null, { - status: 302, - headers: { location: "http://127.0.0.1/repo.git/info/refs" }, - }), - ), - }), - ); - expect(Exit.isFailure(exit)).toBe(true); - }); - - it("drops the authorization header on cross-host redirects", async () => { - const seenAuth: Array = []; - const fetchImpl = testFetch(async (input, init) => { - const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : ""); - const headers = new Headers(init?.headers); - seenAuth.push(headers.get("authorization")); - if (url.hostname === "example.test") { - return new Response(null, { - status: 302, - headers: { location: "https://mirror.test/repo.git/info/refs?service=git-upload-pack" }, - }); - } - return new Response( - new Uint8Array(advertisement("1111111111111111111111111111111111111111")), - { - headers: { "content-type": "application/x-git-upload-pack-advertisement" }, - }, - ); - }); - const result = await checkRefs( - "https://example.test/repo", - { authorization: "Basic secret" }, - fetchImpl, - ); - expect(result.adv.headTarget).toBe("refs/heads/main"); - expect(seenAuth).toEqual(["Basic secret", null]); - }); - - it("enforces pack expansion caps during parse and delta application", async () => { - await expect( - parsePack(packFile([packedObject(3, textEncoder.encode("tiny"))]), { - limits: { maxObjectBytes: 3 }, - }), - ).rejects.toThrow(/repository too large/); - - const base = textEncoder.encode("a"); - const baseSha = await gitObjectId("blob", base); - const delta = concat([deltaVarint(base.length), deltaVarint(1024), Uint8Array.from([1, 120])]); - const refDelta = concat([ - packObjectHeader(7, delta.length), - hexBytes(baseSha), - deflateSync(delta), - ]); - await expect( - parsePack(packFile([packedObject(3, base), refDelta]), { - limits: { maxDeltaResultBytes: 32 }, - }), - ).rejects.toThrow(/repository too large/); - }); - - it("rejects truncated packs, oversized varints, and malformed trees", async () => { - await expect(parsePack(textEncoder.encode("PACK"))).rejects.toThrow(/pack parse error/); - await expect( - parsePack( - packFile([ - Uint8Array.from([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0]), - ]), - ), - ).rejects.toThrow(/varint is too long/); - - const malformedTree = textEncoder.encode("100644 missing-nul"); - const treeSha = await gitObjectId("tree", malformedTree); - const commit = textEncoder.encode(`tree ${treeSha}\n\nbad tree\n`); - const commitSha = await gitObjectId("commit", commit); - const parsed = await parsePack( - packFile([packedObject(1, commit), packedObject(2, malformedTree)]), - ); - expect(() => walkTree(parsed, commitSha)).toThrow(/malformed tree/); - }); - - it("fetches a fixture git repo and avoids pack download when the sha is unchanged", async () => { - const server = await fixtureServer(); - try { - const firstRefs = await run( - checkGitAppSourceRefs({ - url: server.url, - fetch: server.fetch, - allowPrivateHosts: true, - }), - ); - const first = await run( - fetchGitAppSource({ - url: server.url, - fetch: server.fetch, - allowPrivateHosts: true, - }), - ); - expect(first.sourceRef).toBe(firstRefs.sourceRef); - expect(first.files.map((file) => file.path).sort()).toEqual([ - "executor.json", - "tools/greeter.ts", - ]); - expect(server.packRequests()).toBe(1); - - const unchanged = await run( - checkGitAppSourceRefs({ - url: server.url, - fetch: server.fetch, - allowPrivateHosts: true, - }), - ); - expect(unchanged.sourceRef).toBe(first.sourceRef); - expect(server.packRequests()).toBe(1); - - server.advance(); - const second = await run( - fetchGitAppSource({ - url: server.url, - fetch: server.fetch, - allowPrivateHosts: true, - }), - ); - expect(second.sourceRef).not.toBe(first.sourceRef); - expect( - textDecoder.decode(second.files.find((file) => file.path === "tools/greeter.ts")?.bytes), - ).toContain("Greeting v2"); - expect(server.packRequests()).toBe(2); - } finally { - await server.close(); - } - }); - - it("surfaces oversized packfiles as source failures", async () => { - const server = await fixtureServer(); - try { - const exit = await Effect.runPromiseExit( - fetchGitAppSource({ - url: server.url, - fetch: server.fetch, - allowPrivateHosts: true, - maxBytes: 32, - }), - ); - expect(Exit.isFailure(exit)).toBe(true); - } finally { - await server.close(); - } - }); - - it("checks live github and gitlab refs when online", async () => { - if (!(await liveOrSkip("https://github.com"))) return; - if (!(await liveOrSkip("https://gitlab.com"))) return; - const github = await run( - checkGitAppSourceRefs({ url: "https://github.com/octocat/Hello-World" }), - ); - const gitlab = await run( - checkGitAppSourceRefs({ url: "https://gitlab.com/gitlab-org/gitlab-test" }), - ); - expect(github.sourceRef).toMatch(/^[0-9a-f]{40}$/); - expect(gitlab.sourceRef).toMatch(/^[0-9a-f]{40}$/); - }); - - it("fetches a tiny live public repo when online", async () => { - if (!(await liveOrSkip("https://github.com"))) return; - const source = await handFetch("https://github.com/octocat/Hello-World", undefined, { - maxBytes: PUBLISH_LIMITS.maxTotalBytes, - }); - expect(source.ok).toBe(true); - expect(source.sha).toMatch(/^[0-9a-f]{40}$/); - expect(source.files?.some((file) => file.path.toLowerCase().includes("readme"))).toBe(true); - }); -}); - -describe("local-directory app sources", () => { - it("reads local directories and hashes content deterministically", async () => { - const root = await mkdtemp(); - await mkdir(join(root, "tools")); - await mkdir(join(root, "workflows")); - await writeFile(join(root, "executor.json"), JSON.stringify({ description: "Local tools" })); - await writeFile(join(root, "tools", "hello.ts"), "export default {};"); - await writeFile(join(root, "workflows", "later.ts"), "export default {};"); - await symlink(join(root, "tools", "hello.ts"), join(root, "tools", "link.ts")); - - const first = await run(fetchLocalDirectoryAppSource({ path: root })); - const second = await run(fetchLocalDirectoryAppSource({ path: root })); - expect(first.sourceRef).toBe(second.sourceRef); - expect(first.description).toBe("Local tools"); - expect(first.files.map((file) => file.path).sort()).toEqual([ - "executor.json", - "tools/hello.ts", - ]); - expect(first.skipped).toContainEqual({ - path: "tools/link.ts", - reason: "unsupported file type", - }); - expect(first.skipped).toContainEqual({ - path: "workflows/later.ts", - reason: "not supported yet", - }); - }); - - it("rejects unsafe local-directory paths", async () => { - const relative = await Effect.runPromiseExit( - fetchLocalDirectoryAppSource({ path: "relative" }), - ); - const parent = await Effect.runPromiseExit( - fetchLocalDirectoryAppSource({ path: "/tmp/../bad" }), - ); - expect(Exit.isFailure(relative)).toBe(true); - expect(Exit.isFailure(parent)).toBe(true); - }); - - it("lists home by default and immediate subdirectories only", async () => { - const result = await run(listLocalDirectoryDirs({})); - expect(result.path).toBe(homedir()); - expect(result.dirs.every((entry) => entry.path.startsWith(result.path))).toBe(true); - }); - - it("lists subdirectories, filters dotdirs, and can include hidden dirs", async () => { - const root = await mkdtemp(); - await mkdir(join(root, "alpha")); - await mkdir(join(root, ".hidden")); - await writeFile(join(root, "file.txt"), "not a directory"); - - const visible = await run(listLocalDirectoryDirs({ path: root })); - const hidden = await run(listLocalDirectoryDirs({ path: root, includeHidden: true })); - - expect(visible.dirs.map((entry) => entry.name)).toEqual(["alpha"]); - expect(hidden.dirs.map((entry) => entry.name)).toEqual([".hidden", "alpha"]); - }); - - it("reports app source shape and subdirectories with tools folders", async () => { - const root = await mkdtemp(); - const valid = join(root, "valid-app"); - const emptyTools = join(root, "empty-tools"); - const noTools = join(root, "no-tools"); - const nested = join(root, "parent-with-tools"); - await mkdir(join(valid, "tools"), { recursive: true }); - await mkdir(join(valid, "workflows"), { recursive: true }); - await mkdir(join(valid, "ui"), { recursive: true }); - await mkdir(join(valid, "skills"), { recursive: true }); - await mkdir(join(emptyTools, "tools"), { recursive: true }); - await mkdir(noTools, { recursive: true }); - await mkdir(join(nested, "tools"), { recursive: true }); - await writeFile(join(valid, "package.json"), "{}"); - await writeFile(join(valid, "tools", "a.ts"), "export default {};"); - await writeFile(join(valid, "tools", "b.tsx"), "export default {};"); - await writeFile(join(valid, "tools", "bad_name.ts"), "export default {};"); - - const parentListing = await run(listLocalDirectoryDirs({ path: root })); - const validListing = await run(listLocalDirectoryDirs({ path: valid })); - const emptyListing = await run(listLocalDirectoryDirs({ path: emptyTools })); - const noToolsListing = await run(listLocalDirectoryDirs({ path: noTools })); - - expect(parentListing.dirs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "valid-app", hasTools: true }), - expect.objectContaining({ name: "empty-tools", hasTools: true }), - expect.objectContaining({ name: "no-tools", hasTools: false }), - expect.objectContaining({ name: "parent-with-tools", hasTools: true }), - ]), - ); - expect(validListing.source).toEqual({ - toolFiles: ["a.ts", "b.tsx"], - skipped: ["workflows", "ui", "skills"], - hasPackageJson: true, - }); - expect(emptyListing.source).toEqual({ - toolFiles: [], - skipped: [], - hasPackageJson: false, - }); - expect(noToolsListing.source).toEqual({ - toolFiles: [], - skipped: [], - hasPackageJson: false, - }); - }); - - it("rejects parent traversal in directory listings", async () => { - const exit = await Effect.runPromiseExit(listLocalDirectoryDirs({ path: "/tmp/../bad" })); - expect(Exit.isFailure(exit)).toBe(true); - }); - - it("marks symlinked directories and does not descend through them", async () => { - const root = await mkdtemp(); - const target = await mkdtemp(); - await mkdir(join(target, "nested")); - await symlink(target, join(root, "linked")); - - const listed = await run(listLocalDirectoryDirs({ path: root })); - expect(listed.dirs).toContainEqual({ - name: "linked", - path: join(root, "linked"), - isSymlink: true, - hasTools: false, - }); - - const followed = await Effect.runPromiseExit( - listLocalDirectoryDirs({ path: join(root, "linked") }), - ); - expect(Exit.isFailure(followed)).toBe(true); - }); - - it("does not read symlinks that escape the source root", async () => { - const root = await mkdtemp(); - const outside = await mkdtemp(); - await mkdir(join(root, "tools")); - await writeFile(join(outside, "secret.ts"), "export default 'secret';"); - await symlink(join(outside, "secret.ts"), join(root, "tools", "secret.ts")); - - const result = await run(fetchLocalDirectoryAppSource({ path: root })); - expect(result.files).toEqual([]); - expect(result.skipped).toContainEqual({ - path: "tools/secret.ts", - reason: "unsupported file type", - }); - }); -}); - -const mkdtemp = (): Promise => - import("node:fs/promises").then(({ mkdtemp }) => mkdtemp(join(tmpdir(), "apps-src-"))); diff --git a/packages/plugins/apps/src/source/app-source.ts b/packages/plugins/apps/src/source/app-source.ts deleted file mode 100644 index 76e6bfbbfc..0000000000 --- a/packages/plugins/apps/src/source/app-source.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* oxlint-disable executor/no-unknown-error-message -- boundary: SyncDiagnostic intentionally serializes typed source and publish error messages */ -import { Data, Effect } from "effect"; - -import { PublishError, type PublishFile } from "../pipeline/publish"; -import { redactUrl } from "../git-client/url-security"; - -export interface AppSourceSnapshot { - readonly files: readonly PublishFile[]; - readonly sourceRef: string; - readonly description?: string; -} - -export interface AppSource { - readonly fetch: () => Effect.Effect; -} - -export class AppSourceError extends Data.TaggedError("AppSourceError")<{ - readonly message: string; - readonly path?: string; - readonly status?: number; - readonly cause?: unknown; -}> {} - -export interface SyncDiagnostic { - readonly stage: "source" | "discover" | "bundle" | "collect" | "project"; - readonly message: string; - readonly diagnostics?: readonly { readonly path: string; readonly message: string }[]; -} - -export const sourceErrorToDiagnostic = (error: AppSourceError): SyncDiagnostic => ({ - stage: "source", - message: error.message, - ...(error.path ? { diagnostics: [{ path: redactUrl(error.path), message: error.message }] } : {}), -}); - -export const publishErrorToDiagnostic = (error: PublishError): SyncDiagnostic => ({ - stage: error.stage, - message: error.message, - diagnostics: error.diagnostics, -}); diff --git a/packages/plugins/apps/src/source/fixtures/git-fixture-shas.txt b/packages/plugins/apps/src/source/fixtures/git-fixture-shas.txt deleted file mode 100644 index c09ab44391..0000000000 --- a/packages/plugins/apps/src/source/fixtures/git-fixture-shas.txt +++ /dev/null @@ -1,2 +0,0 @@ -acc16ee289bb739b9b68e57799cb1fc3e914f2f1 -40fb4edd626eaa2687e2906918de3d28ff160c1c diff --git a/packages/plugins/apps/src/source/fixtures/git-fixture-v1.pack b/packages/plugins/apps/src/source/fixtures/git-fixture-v1.pack deleted file mode 100644 index 1efea539dc1bf493d1e6e7da9bfa8c700bf1bf70..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 507 zcmV1bCb?G%zqTF;PgZNKGy+$uH8& zD$dVikn2BnHu$FJlk1;lAGGQFu5-Uovc$vy2oy^4^K*(Ba(yq)JTEt~>Tv0yGDaEW z8()%B7Xbh}_#Z>D0(hLOR!T`NPAE)ZM4 z4|tqSjy(>-FbsuvPGMyNfsk0+18@O0#8BE-2ukDNItYY%cbvk&X8C>Z#}A1`=}jGa zA8}v~G*K@+ki!#gR+g$s$<+%yXVkzZ2mLcFi+gOvC-odFa(e;P#|u4nZbLq%td&*h zE5TGg-_WWFNjO2&V=QWH2K-T@iX}K3Gi^w4&tA|~baaJtr}Divu?vvpT(B~$K(j8f>uG%_Y|6xQLOHMxL+^PTo diff --git a/packages/plugins/apps/src/source/fixtures/git-fixture-v2.pack b/packages/plugins/apps/src/source/fixtures/git-fixture-v2.pack deleted file mode 100644 index bc7206f2a9f25326f947e58be301f569d1a6650d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 850 zcmWG=boORoU|<4b&Y3(FbEckl$hm-R>t)fQ<*XAC+T@&0Zx!{zN>c$OEx;BcW zO}$~-`1ry~_v>ZVIz2fXCtC?&TMlbT|DGmiBrf5bIEyn$$6S(uxZoAB$G~M$=)T|d23IvOX>oDbd-#%Nu zLk2vp<$j5&OpOyFZZj7!njHza70%)H_<Ugfq)zj8p0{jaf3cXiHD^{+D8Mb(T~j&s|VN*`8L*4)-Jdzvl@?cHXsG{)d&nIwat co => - Effect.try({ - try: () => { - const url = validateGitFetchUrl(input.trim(), options); - if (hasTokenLikeQuery(url)) { - throw new AppSourceError({ - message: "git source URL must not include token query parameters", - path: redactUrl(input), - }); - } - return url; - }, - catch: (cause) => - cause instanceof AppSourceError - ? cause - : new AppSourceError({ - message: cause instanceof Error ? cause.message : "git source URL is not valid", - path: redactUrl(input), - cause, - }), - }); - -const descriptionFor = ( - files: readonly PublishFile[], -): Effect.Effect => - Effect.gen(function* () { - const file = files.find((item) => item.path === "executor.json"); - if (!file) return undefined; - const parsed = yield* decodeExecutorJson(textDecoder.decode(file.bytes)).pipe( - Effect.mapError( - (cause) => - new AppSourceError({ - message: "executor.json is not valid JSON", - path: "executor.json", - cause, - }), - ), - ); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; - const description = (parsed as { readonly description?: unknown }).description; - return typeof description === "string" ? description : undefined; - }); - -const relevantFiles = ( - files: readonly { readonly path: string; readonly bytes: Uint8Array }[], -): { readonly files: readonly PublishFile[]; readonly skipped: readonly SourceSkippedFile[] } => { - const out: PublishFile[] = []; - const skipped: SourceSkippedFile[] = []; - for (const file of files) { - if (!isRelevantAppSourcePath(file.path)) { - const classified = classifyAppSourcePath(file.path); - if (classified !== "fetch") skipped.push(classified); - continue; - } - out.push({ path: file.path, bytes: file.bytes }); - } - return { files: out, skipped }; -}; - -export const checkGitAppSourceRefs = ( - input: GitAppSourceInput, -): Effect.Effect => - Effect.gen(function* () { - yield* parseGitSourceUrl(input.url, { allowPrivateHosts: input.allowPrivateHosts }); - const checked = yield* Effect.tryPromise({ - try: () => - handRefsCheck(input.url, input.ref, { - ...(input.token ? { token: input.token } : {}), - ...(input.fetch ? { fetchImpl: input.fetch } : {}), - allowPrivateHosts: input.allowPrivateHosts, - }), - catch: (cause) => - new AppSourceError({ - message: `git refs check failed: ${cause instanceof Error ? cause.message : String(cause)}`, - path: redactUrl(input.url), - cause, - }), - }); - if (!checked.ok || !checked.sha || !checked.resolvedRef) { - return yield* new AppSourceError({ - message: checked.error ?? "git refs check failed", - path: redactUrl(input.url), - }); - } - return { sourceRef: checked.sha, resolvedRef: checked.resolvedRef }; - }); - -export const fetchGitAppSource = ( - input: GitAppSourceInput, -): Effect.Effect => - Effect.gen(function* () { - yield* parseGitSourceUrl(input.url, { allowPrivateHosts: input.allowPrivateHosts }); - const fetched = yield* Effect.tryPromise({ - try: () => - handFetch(input.url, input.ref, { - ...(input.token ? { token: input.token } : {}), - ...(input.fetch ? { fetchImpl: input.fetch } : {}), - ...(input.maxBytes ? { maxBytes: input.maxBytes } : {}), - allowPrivateHosts: input.allowPrivateHosts, - }), - catch: (cause) => - new AppSourceError({ - message: `git fetch failed: ${cause instanceof Error ? cause.message : String(cause)}`, - path: redactUrl(input.url), - cause, - }), - }); - if (fetched.truncated === true) { - return yield* new AppSourceError({ - message: fetched.error ?? "git repository is too large", - path: redactUrl(input.url), - }); - } - if (!fetched.ok || !fetched.sha || !fetched.resolvedRef || !fetched.files) { - return yield* new AppSourceError({ - message: fetched.error ?? "git fetch failed", - path: redactUrl(input.url), - }); - } - const collected = relevantFiles(fetched.files); - const limitError = enforcePublishLimits(collected.files); - if (limitError) return yield* limitError; - return { - url: input.url, - ...(input.ref ? { ref: input.ref } : {}), - resolvedRef: fetched.resolvedRef, - files: collected.files, - sourceRef: fetched.sha, - description: yield* descriptionFor(collected.files), - skipped: collected.skipped, - packBytes: fetched.packBytes ?? 0, - }; - }); - -export const makeGitAppSource = (input: GitAppSourceInput) => ({ - fetch: () => fetchGitAppSource(input), -}); diff --git a/packages/plugins/apps/src/source/local-directory-source.ts b/packages/plugins/apps/src/source/local-directory-source.ts deleted file mode 100644 index 4fd69a3b84..0000000000 --- a/packages/plugins/apps/src/source/local-directory-source.ts +++ /dev/null @@ -1,362 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-instanceof-tagged-error -- boundary: filesystem path validation and fs errors normalize to AppSourceError */ -import { createHash } from "node:crypto"; -import { constants } from "node:fs"; -import { lstat, open, readdir, realpath, stat } from "node:fs/promises"; -import { homedir } from "node:os"; -import { dirname, isAbsolute, resolve, sep } from "node:path"; - -import { Effect, Schema } from "effect"; - -import { RESERVED_ARTIFACT_DIRS, toolKeyFromPath } from "../pipeline/discover"; -import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish"; -import { AppSourceError, type AppSourceSnapshot } from "./app-source"; -import { - classifyAppSourcePath, - isRelevantAppSourcePath, - type SourceSkippedFile, -} from "./relevant-files"; - -export interface LocalDirectoryAppSourceInput { - readonly path: string; -} - -export interface LocalDirectoryAppSourceSnapshot extends AppSourceSnapshot { - readonly root: string; - readonly skipped: readonly SourceSkippedFile[]; -} - -export interface LocalDirectoryDirsInput { - readonly path?: string; - readonly includeHidden?: boolean; -} - -export interface LocalDirectoryDirEntry { - readonly name: string; - readonly path: string; - readonly isSymlink: boolean; - readonly hasTools: boolean; -} - -export interface LocalDirectorySourceShape { - readonly toolFiles: readonly string[]; - readonly skipped: readonly string[]; - readonly hasPackageJson: boolean; -} - -export interface LocalDirectoryDirsResult { - readonly path: string; - readonly parent: string | null; - readonly dirs: readonly LocalDirectoryDirEntry[]; - readonly source: LocalDirectorySourceShape; -} - -const sha256 = (bytes: Uint8Array | string): string => - createHash("sha256").update(bytes).digest("hex"); - -export const validateLocalDirectoryPath = (path: string): Effect.Effect => - Effect.try({ - try: () => { - if (!isAbsolute(path)) { - throw new AppSourceError({ message: "local-directory source path must be absolute", path }); - } - const parts = path.split(/[\\/]+/); - if (parts.includes("..")) { - throw new AppSourceError({ - message: "local-directory source path must not contain ..", - path, - }); - } - return resolve(path); - }, - catch: (cause) => - cause instanceof AppSourceError - ? cause - : new AppSourceError({ message: "invalid local-directory source path", path, cause }), - }); - -const isUnderRoot = (root: string, candidate: string): boolean => - candidate === root || candidate.startsWith(`${root}${sep}`); - -const readRegularFileNoFollow = ( - root: string, - rootReal: string, - child: string, -): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const fullPath = `${root}${sep}${child}`; - const before = await lstat(fullPath); - if (!before.isFile()) throw new Error("not a regular file"); - const resolved = await realpath(fullPath); - if (!isUnderRoot(rootReal, resolved)) throw new Error("file resolves outside source root"); - const handle = await open(fullPath, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - const after = await handle.stat(); - if (!after.isFile()) throw new Error("not a regular file"); - if (before.dev !== after.dev || before.ino !== after.ino) { - throw new Error("file changed while being read"); - } - return await handle.readFile(); - } finally { - await handle.close(); - } - }, - catch: (cause) => - new AppSourceError({ - message: `failed to read local-directory file: ${child}`, - path: child, - cause, - }), - }); - -const walk = ( - root: string, - rootReal: string, - relative = "", -): Effect.Effect< - { readonly files: readonly PublishFile[]; readonly skipped: readonly SourceSkippedFile[] }, - AppSourceError | PublishError -> => - Effect.gen(function* () { - const dir = relative ? `${root}${sep}${relative}` : root; - const entries = yield* Effect.tryPromise({ - try: () => readdir(dir, { withFileTypes: true }), - catch: (cause) => - new AppSourceError({ - message: `failed to read local-directory source: ${relative || root}`, - path: relative || root, - cause, - }), - }); - const files: PublishFile[] = []; - const skipped: SourceSkippedFile[] = []; - for (const entry of entries) { - const child = relative ? `${relative}/${entry.name}` : entry.name; - if (entry.isSymbolicLink()) { - skipped.push({ path: child, reason: "unsupported file type" }); - continue; - } - if (entry.isDirectory()) { - if ( - child === "tools" || - child === "workflows" || - child === "ui" || - child === "skills" || - child.startsWith("tools/") || - child.startsWith("workflows/") || - child.startsWith("ui/") || - child.startsWith("skills/") - ) { - const nested = yield* walk(root, rootReal, child); - files.push(...nested.files); - skipped.push(...nested.skipped); - } - continue; - } - if (!entry.isFile()) { - skipped.push({ path: child, reason: "unsupported file type" }); - continue; - } - const classified = classifyAppSourcePath(child); - if (classified !== "fetch") { - skipped.push(classified); - continue; - } - if (!isRelevantAppSourcePath(child)) continue; - const bytes = yield* readRegularFileNoFollow(root, rootReal, child); - files.push({ path: child, bytes }); - } - const limitError = enforcePublishLimits(files); - if (limitError) return yield* limitError; - return { files, skipped }; - }); - -const sourceRefFor = (files: readonly PublishFile[]): string => { - const manifest = files - .map((file) => `${file.path}:${sha256(file.bytes)}`) - .sort() - .join("\n"); - return sha256(manifest); -}; - -const decodeExecutorJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); - -const descriptionFor = ( - files: readonly PublishFile[], -): Effect.Effect => - Effect.gen(function* () { - const file = files.find((item) => item.path === "executor.json"); - if (!file) return undefined; - const parsed = yield* decodeExecutorJson(new TextDecoder().decode(file.bytes)).pipe( - Effect.mapError( - (cause) => - new AppSourceError({ - message: "executor.json is not valid JSON", - path: "executor.json", - cause, - }), - ), - ); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; - const description = (parsed as { readonly description?: unknown }).description; - return typeof description === "string" ? description : undefined; - }); - -const lstatOrNull = (path: string): Effect.Effect> | null> => - Effect.tryPromise({ - try: () => lstat(path), - catch: (cause) => - new AppSourceError({ message: "failed to stat local-directory path", path, cause }), - }).pipe(Effect.catch(() => Effect.succeed(null))); - -const hasNonSymlinkDirectory = (path: string): Effect.Effect => - Effect.gen(function* () { - const entry = yield* lstatOrNull(path); - return entry !== null && entry.isDirectory() && !entry.isSymbolicLink(); - }); - -const hasNonSymlinkFile = (path: string): Effect.Effect => - Effect.gen(function* () { - const entry = yield* lstatOrNull(path); - return entry !== null && entry.isFile() && !entry.isSymbolicLink(); - }); - -const listLocalDirectorySourceShape = ( - root: string, -): Effect.Effect => - Effect.gen(function* () { - const toolsPath = `${root}${sep}tools`; - const toolsStat = yield* lstatOrNull(toolsPath); - const toolFiles: string[] = []; - if (toolsStat?.isDirectory() && !toolsStat.isSymbolicLink()) { - const toolEntries = yield* Effect.tryPromise({ - try: () => readdir(toolsPath, { withFileTypes: true }), - catch: (cause) => - new AppSourceError({ - message: "failed to read local-directory tools folder", - path: toolsPath, - cause, - }), - }); - for (const entry of toolEntries) { - if (!entry.isFile()) continue; - if (toolKeyFromPath(`tools/${entry.name}`) === null) continue; - toolFiles.push(entry.name); - } - toolFiles.sort((a, b) => a.localeCompare(b)); - } - const skipped: string[] = []; - for (const name of RESERVED_ARTIFACT_DIRS) { - if (yield* hasNonSymlinkDirectory(`${root}${sep}${name}`)) skipped.push(name); - } - return { - toolFiles: toolFiles.slice(0, 20), - skipped, - hasPackageJson: yield* hasNonSymlinkFile(`${root}${sep}package.json`), - }; - }); - -export const fetchLocalDirectoryAppSource = ( - input: LocalDirectoryAppSourceInput, -): Effect.Effect => - Effect.gen(function* () { - const root = yield* validateLocalDirectoryPath(input.path); - const rootReal = yield* Effect.tryPromise({ - try: () => realpath(root), - catch: (cause) => - new AppSourceError({ - message: "failed to resolve local-directory source root", - path: root, - cause, - }), - }); - const collected = yield* walk(root, rootReal); - return { - root, - files: collected.files, - sourceRef: sourceRefFor(collected.files), - description: yield* descriptionFor(collected.files), - skipped: collected.skipped, - }; - }); - -export const listLocalDirectoryDirs = ( - input: LocalDirectoryDirsInput, -): Effect.Effect => - Effect.gen(function* () { - const root = yield* validateLocalDirectoryPath(input.path?.trim() || homedir()); - const rootStat = yield* Effect.tryPromise({ - try: () => lstat(root), - catch: (cause) => - new AppSourceError({ - message: "failed to read local-directory source", - path: root, - cause, - }), - }); - if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { - return yield* new AppSourceError({ - message: "local-directory source path must be a directory", - path: root, - }); - } - const entries = yield* Effect.tryPromise({ - try: () => readdir(root), - catch: (cause) => - new AppSourceError({ - message: "failed to read local-directory source", - path: root, - cause, - }), - }); - const dirs: LocalDirectoryDirEntry[] = []; - const source = yield* listLocalDirectorySourceShape(root); - for (const name of entries) { - if (!input.includeHidden && name.startsWith(".")) continue; - const child = `${root}${sep}${name}`; - const entryStat = yield* Effect.tryPromise({ - try: () => lstat(child), - catch: (cause) => - new AppSourceError({ - message: "failed to stat local-directory child", - path: child, - cause, - }), - }).pipe(Effect.catch(() => Effect.succeed(null))); - if (!entryStat) continue; - if (entryStat.isDirectory()) { - dirs.push({ - name, - path: child, - isSymlink: false, - hasTools: yield* hasNonSymlinkDirectory(`${child}${sep}tools`), - }); - continue; - } - if (!entryStat.isSymbolicLink()) continue; - const targetStat = yield* Effect.tryPromise({ - try: () => stat(child), - catch: (cause) => - new AppSourceError({ - message: "failed to stat local-directory symlink target", - path: child, - cause, - }), - }).pipe(Effect.catch(() => Effect.succeed(null))); - if (targetStat?.isDirectory()) { - dirs.push({ name, path: child, isSymlink: true, hasTools: false }); - } - } - dirs.sort((a, b) => a.name.localeCompare(b.name)); - const parent = dirname(root); - return { - path: root, - parent: parent === root ? null : parent, - dirs, - source, - }; - }); - -export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({ - fetch: () => fetchLocalDirectoryAppSource(input), -}); diff --git a/packages/plugins/apps/src/source/relevant-files.ts b/packages/plugins/apps/src/source/relevant-files.ts deleted file mode 100644 index 208cddf76e..0000000000 --- a/packages/plugins/apps/src/source/relevant-files.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type SourceSkippedFile = { - readonly path: string; - readonly reason: "not supported yet" | "unsupported file type" | "ignored"; -}; - -const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/; -const DEFERRED_RE = /^(workflows|ui|skills)\//; -const ROOT_SUPPORT_FILES = new Set([ - "executor.json", - "package.json", - "bun.lock", - "package-lock.json", - "pnpm-lock.yaml", - "yarn.lock", -]); - -export const isRelevantAppSourcePath = (path: string): boolean => - ROOT_SUPPORT_FILES.has(path) || TOOL_RE.test(path); - -export const classifyAppSourcePath = (path: string): "fetch" | SourceSkippedFile => { - if (isRelevantAppSourcePath(path)) return "fetch"; - if (DEFERRED_RE.test(path)) return { path, reason: "not supported yet" }; - return { path, reason: "ignored" }; -}; diff --git a/packages/plugins/apps/src/standard-schema.ts b/packages/plugins/apps/src/standard-schema.ts deleted file mode 100644 index 934cdffabd..0000000000 --- a/packages/plugins/apps/src/standard-schema.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type StandardSchemaIssue = { - readonly message: string; - readonly path?: readonly (PropertyKey | { readonly key: PropertyKey })[]; -}; - -export interface StandardSchemaV1 { - readonly "~standard": { - readonly version: 1; - readonly vendor: string; - readonly validate: ( - value: Input, - ) => - | { readonly value: Output } - | { readonly issues: readonly StandardSchemaIssue[] } - | Promise<{ readonly value: Output } | { readonly issues: readonly StandardSchemaIssue[] }>; - }; -} - -export namespace StandardSchemaV1 { - export type InferOutput = T extends StandardSchemaV1 ? Output : never; -} diff --git a/packages/plugins/apps/src/testing/conformance.ts b/packages/plugins/apps/src/testing/conformance.ts deleted file mode 100644 index b476180b53..0000000000 --- a/packages/plugins/apps/src/testing/conformance.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; - -import { bundleEntry } from "../pipeline/bundle"; -import type { AppToolExecutor } from "../executor/app-tool-executor"; - -const bundle = (source: string) => - bundleEntry({ - files: new Map([["tools/conformance.ts", source]]), - entry: "tools/conformance.ts", - }); - -export const appToolExecutorConformance = ( - name: string, - makeExecutor: () => AppToolExecutor, -): void => { - describe(`${name} app tool executor conformance`, () => { - it.effect("collects descriptors deterministically", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool, integration } from "executor:app"; - export default defineTool({ - description: "Conformance", - integrations: { crm: integration("dealcloud") }, - input: z.object({ value: z.string() }), - output: z.object({ ok: z.boolean() }), - handler() { return { ok: true }; }, - }); - `); - const collected = yield* makeExecutor().collect(bundled.code, { - fileSlug: "conformance", - sourcePath: "tools/conformance.ts", - }); - expect(collected.tools).toMatchObject([ - { - toolName: "conformance", - integrations: { crm: { slug: "dealcloud", mode: "one" } }, - }, - ]); - }), - ); - - it.effect("collects Standard Schema input JSON schema extensions", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool } from "executor:app"; - export default defineTool({ - description: "Zod schema", - input: z.object({ name: z.string(), count: z.number().optional() }), - handler(input) { return input; }, - }); - `); - const collected = yield* makeExecutor().collect(bundled.code, { - fileSlug: "conformance", - sourcePath: "tools/conformance.ts", - }); - const inputSchema = collected.tools[0]?.inputSchema as { - readonly properties?: Record; - readonly required?: readonly string[]; - }; - expect(inputSchema.properties?.name).toMatchObject({ type: "string" }); - expect(inputSchema.properties?.count).toBeDefined(); - expect(inputSchema.required ?? []).toContain("name"); - expect(inputSchema.required ?? []).not.toContain("count"); - expect(inputSchema).not.toHaveProperty("$schema"); - }), - ); - - it.effect("rejects integration keys that collide with Standard Schema input fields", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool, integration } from "executor:app"; - export default defineTool({ - description: "Collision", - integrations: { crm: integration("dealcloud") }, - input: z.object({ crm: z.string() }), - handler(input) { return input; }, - }); - `); - const error = yield* makeExecutor() - .collect(bundled.code, { - fileSlug: "conformance", - sourcePath: "tools/conformance.ts", - }) - .pipe(Effect.flip); - expect(error).toMatchObject({ - kind: "collect", - message: expect.stringContaining('integration key "crm" collides with input field'), - diagnostics: [ - { - path: "tools/conformance.ts", - message: "integration key collides with input: crm", - }, - ], - }); - }), - ); - - it.effect("rejects Standard Schema libraries without JSON schema extensions", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { defineTool } from "executor:app"; - const input = { - "~standard": { - vendor: "fake", - version: 1, - validate(value) { return { value }; }, - }, - }; - export default defineTool({ - description: "Missing JSON schema extension", - input, - handler(value) { return value; }, - }); - `); - const error = yield* makeExecutor() - .collect(bundled.code, { - fileSlug: "conformance", - sourcePath: "tools/conformance.ts", - }) - .pipe(Effect.flip); - expect(error).toMatchObject({ - kind: "collect", - message: expect.stringContaining( - "schema library does not expose the Standard Schema jsonSchema extension", - ), - diagnostics: [ - { - path: "tools/conformance.ts", - message: - "input schema library does not expose the Standard Schema jsonSchema extension", - }, - ], - }); - }), - ); - - it.effect("invokes handlers with split input and integrations", () => - Effect.gen(function* () { - const bundled = yield* bundle(` - import { z } from "zod"; - import { defineTool, integration } from "executor:app"; - export default defineTool({ - description: "Conformance invoke", - integrations: { crm: integration("dealcloud") }, - input: z.object({ value: z.string() }), - output: z.object({ result: z.string() }), - async handler({ value }, { crm }) { - const response = await crm.echo({ value }); - return { result: response.value }; - }, - }); - `); - const output = yield* makeExecutor().invoke( - bundled.code, - { toolName: "conformance" }, - { crm: "tools.dealcloud.org.main", value: "ok" }, - { call: async (_path, args) => args }, - { timeoutMs: 1000 }, - ); - expect(output.output).toEqual({ result: "ok" }); - }), - ); - }); -}; diff --git a/packages/plugins/apps/src/testing/index.ts b/packages/plugins/apps/src/testing/index.ts deleted file mode 100644 index 0b37298958..0000000000 --- a/packages/plugins/apps/src/testing/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { makeInProcessAppToolExecutor } from "../executor/app-tool-executor"; -export { makeAppsStore } from "../plugin/store"; -export { appToolExecutorConformance } from "./conformance"; diff --git a/packages/plugins/apps/src/vite.ts b/packages/plugins/apps/src/vite.ts deleted file mode 100644 index 0de2d37d51..0000000000 --- a/packages/plugins/apps/src/vite.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { Plugin } from "vite"; - -import { bundledWorkerBundler } from "./pipeline/worker-bundler-artifact"; - -const virtualId = "virtual:executor/worker-bundler-artifact"; -const resolvedVirtualId = `\0${virtualId}`; -const clientAssetsDir = "dist/client/assets"; - -const digest = (value: string | Uint8Array): string => - createHash("sha256").update(value).digest("hex").slice(0, 12); - -const artifactPaths = (artifact: { - readonly source: string; - readonly wasm: Uint8Array; -}): { - readonly sourcePath: string; - readonly wasmPath: string; -} => ({ - sourcePath: `/assets/worker-bundler-source-${digest(artifact.source)}.js`, - wasmPath: `/assets/worker-bundler-esbuild-wasm-${digest(artifact.wasm)}.wasm`, -}); - -export const workerBundlerArtifact = (): Plugin => { - let command: "build" | "serve" = "build"; - - return { - name: "executor-worker-bundler-artifact", - configResolved(config) { - command = config.command; - }, - resolveId(id) { - return id === virtualId ? resolvedVirtualId : null; - }, - async load(id) { - if (id !== resolvedVirtualId) return null; - const artifact = await bundledWorkerBundler(); - const paths = artifactPaths(artifact); - const output = [ - `export const sourcePath = ${JSON.stringify(paths.sourcePath)};`, - `export const wasmPath = ${JSON.stringify(paths.wasmPath)};`, - ]; - - if (command === "serve") { - output.push( - `export const source = ${JSON.stringify(artifact.source)};`, - `export const wasmBase64 = ${JSON.stringify(Buffer.from(artifact.wasm).toString("base64"))};`, - "export default { sourcePath, wasmPath, source, wasmBase64 };", - ); - } else { - output.push( - "export const source = undefined;", - "export const wasmBase64 = undefined;", - "export default { sourcePath, wasmPath, source, wasmBase64 };", - ); - } - - return output.join("\n"); - }, - async closeBundle() { - if (command !== "build") return; - const artifact = await bundledWorkerBundler(); - const paths = artifactPaths(artifact); - const outputDir = join(process.cwd(), clientAssetsDir); - await mkdir(outputDir, { recursive: true }); - await Promise.all([ - writeFile(join(process.cwd(), "dist/client", paths.sourcePath), artifact.source), - writeFile(join(process.cwd(), "dist/client", paths.wasmPath), artifact.wasm), - ]); - }, - }; -}; diff --git a/packages/plugins/apps/tsconfig.json b/packages/plugins/apps/tsconfig.json deleted file mode 100644 index d8cf3d0daf..0000000000 --- a/packages/plugins/apps/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "strict": true, - "skipLibCheck": true, - "lib": ["ES2022", "DOM"], - "types": ["bun-types", "node"], - "noUnusedLocals": true, - "noImplicitOverride": true, - "jsx": "react-jsx", - "plugins": [ - { - "name": "@effect/language-service", - "ignoreEffectSuggestionsInTscExitCode": true, - "ignoreEffectWarningsInTscExitCode": true, - "diagnosticSeverity": {} - } - ] - }, - "include": ["src/**/*.ts", "src/**/*.tsx"] -} diff --git a/packages/plugins/apps/tsup.config.ts b/packages/plugins/apps/tsup.config.ts deleted file mode 100644 index 5cc9dd3fdc..0000000000 --- a/packages/plugins/apps/tsup.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { - index: "src/index.ts", - api: "src/api.ts", - authoring: "src/authoring.ts", - "testing/index": "src/testing/index.ts", - vite: "src/vite.ts", - }, - format: ["esm"], - dts: false, - sourcemap: true, - clean: true, - external: [/^@executor-js\//, /^effect/, /^@effect\//, "esbuild", "zod", "vite"], -}); diff --git a/packages/plugins/apps/vitest.config.ts b/packages/plugins/apps/vitest.config.ts deleted file mode 100644 index 3ff789fe14..0000000000 --- a/packages/plugins/apps/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["src/**/*.test.ts"], - exclude: ["src/**/*.worker.test.ts"], - testTimeout: 30_000, - hookTimeout: 30_000, - }, -}); diff --git a/packages/plugins/apps/vitest.worker.config.ts b/packages/plugins/apps/vitest.worker.config.ts deleted file mode 100644 index 4120606830..0000000000 --- a/packages/plugins/apps/vitest.worker.config.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: worker-pool test config prepares a Vite artifact before tests start */ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; -import { defineConfig } from "vitest/config"; - -const packageDir = dirname(fileURLToPath(import.meta.url)); -const workerBundlerDist = join(packageDir, "node_modules", "@cloudflare", "worker-bundler", "dist"); - -const createWorkerBundlerArtifact = async (): Promise<{ - readonly source: string; - readonly wasmBase64: string; -}> => { - if ( - !existsSync(join(workerBundlerDist, "index.js")) || - !existsSync(join(workerBundlerDist, "esbuild.wasm")) - ) { - throw new Error(`@cloudflare/worker-bundler dist files not found at ${workerBundlerDist}`); - } - const { build } = await import("esbuild"); - const [result, wasm] = await Promise.all([ - build({ - entryPoints: [join(workerBundlerDist, "index.js")], - bundle: true, - format: "esm", - platform: "browser", - write: false, - external: ["./esbuild.wasm"], - logLevel: "silent", - }), - readFile(join(workerBundlerDist, "esbuild.wasm")), - ]); - const source = result.outputFiles[0]?.text; - if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); - return { source, wasmBase64: wasm.toString("base64") }; -}; - -const artifact = await createWorkerBundlerArtifact(); - -export default defineConfig({ - define: { - __WORKER_BUNDLER_SOURCE__: JSON.stringify(artifact.source), - __WORKER_BUNDLER_WASM_BASE64__: JSON.stringify(artifact.wasmBase64), - }, - plugins: [ - cloudflareTest({ - wrangler: { configPath: "./wrangler.worker.jsonc" }, - }), - ], - test: { - include: ["src/**/*.worker.test.ts"], - testTimeout: 120_000, - hookTimeout: 120_000, - }, -}); diff --git a/packages/plugins/apps/wrangler.worker.jsonc b/packages/plugins/apps/wrangler.worker.jsonc deleted file mode 100644 index 2291c1104b..0000000000 --- a/packages/plugins/apps/wrangler.worker.jsonc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "plugin-apps-worker-test", - "compatibility_date": "2025-06-01", - "compatibility_flags": ["nodejs_compat"], - "worker_loaders": [ - { - "binding": "LOADER", - }, - ], -} diff --git a/packages/react/src/lib/integration-detail-tabs.ts b/packages/react/src/lib/integration-detail-tabs.ts index 90495205df..70b535b0a0 100644 --- a/packages/react/src/lib/integration-detail-tabs.ts +++ b/packages/react/src/lib/integration-detail-tabs.ts @@ -1,16 +1,8 @@ +// "source" is still accepted from the URL so links minted while the apps +// plugin existed degrade to the accounts tab instead of failing validation. export type IntegrationDetailSearchTab = "accounts" | "source" | "tools"; export type IntegrationDetailInternalTab = "accounts" | "tools"; -export const integrationDetailTabForAddCompletion = ( - pluginKey: string, -): IntegrationDetailSearchTab | undefined => (pluginKey === "apps" ? "source" : undefined); - export const integrationDetailInternalTabFromSearch = ( tab: IntegrationDetailSearchTab | undefined, ): IntegrationDetailInternalTab => (tab === "tools" ? "tools" : "accounts"); - -export const integrationDetailSearchTabForInternal = ( - integrationKind: string | undefined, - tab: IntegrationDetailInternalTab, -): IntegrationDetailSearchTab => - tab === "tools" ? "tools" : integrationKind === "apps" ? "source" : "accounts"; diff --git a/packages/react/src/pages/integration-add.tsx b/packages/react/src/pages/integration-add.tsx index 1c6f742703..f758a3c49b 100644 --- a/packages/react/src/pages/integration-add.tsx +++ b/packages/react/src/pages/integration-add.tsx @@ -5,7 +5,6 @@ import { useIntegrationPlugins } from "@executor-js/sdk/client"; import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { useExecutorDocumentTitle } from "../lib/document-title"; -import { integrationDetailTabForAddCompletion } from "../lib/integration-detail-tabs"; // --------------------------------------------------------------------------- // Page @@ -64,13 +63,12 @@ export function AddIntegrationPage(props: { ...(slug ? { integration_slug: slug } : {}), }); refreshIntegrations(); - const tab = integrationDetailTabForAddCompletion(pluginKey); void navigate( slug ? { to: "/{-$orgSlug}/integrations/$namespace", params: { namespace: slug }, - search: tab ? { tab } : {}, + search: {}, } : { to: "/{-$orgSlug}" }, ); diff --git a/packages/react/src/pages/integration-detail.tsx b/packages/react/src/pages/integration-detail.tsx index c575a2c76c..cd61270a02 100644 --- a/packages/react/src/pages/integration-detail.tsx +++ b/packages/react/src/pages/integration-detail.tsx @@ -38,7 +38,6 @@ import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; import { integrationDetailInternalTabFromSearch, - integrationDetailSearchTabForInternal, type IntegrationDetailInternalTab, type IntegrationDetailSearchTab, } from "../lib/integration-detail-tabs"; @@ -119,7 +118,6 @@ export function IntegrationDetailPage(props: { const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null; useExecutorDocumentTitle(integrationData?.name || namespace); const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in"; - const isAppsIntegration = integrationData?.kind === "apps"; const currentTab = isBuiltInIntegration ? "tools" : activeTab; const canRefresh = integrationData?.canRefresh ?? false; const canRemove = integrationData?.canRemove ?? false; @@ -169,9 +167,6 @@ export function IntegrationDetailPage(props: { // Find the plugin edit component based on integration kind const editPlugin = useMemo(() => { if (!integrationData) return null; - if (integrationData.kind === "apps") { - return integrationPlugins.find((p) => p.key === "apps") ?? null; - } return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null; }, [integrationData, integrationPlugins]); @@ -373,7 +368,7 @@ export function IntegrationDetailPage(props: { to: "/{-$orgSlug}/integrations/$namespace", params: { namespace }, search: { - tab: integrationDetailSearchTabForInternal(integrationData?.kind, nextTab), + tab: nextTab, }, }); }; @@ -394,7 +389,7 @@ export function IntegrationDetailPage(props: {
- {!confirmDelete && !isBuiltInIntegration && !isAppsIntegration && integrationData && ( + {!confirmDelete && !isBuiltInIntegration && integrationData && ( @@ -412,7 +407,6 @@ export function IntegrationDetailPage(props: { )} {canRemove && - !isAppsIntegration && (confirmDelete ? (
+ )} +
+ ); + } + + return ( + <> +
+
+ User + Created + Last seen + Connections +
+ {rows.map((user: AdminUserRow) => ( + // oxlint-disable-next-line react/forbid-elements + + ))} +
+ + {(hasNext || offset > 0) && ( +
+ + Page {pageNumber(offset, ADMIN_USERS_PAGE_SIZE)} + +
+ + +
+
+ )} + + ); + }, + })} + + !open && setSelected(null)}> + + {selected && ( + <> + + + + Created {formatAdminDate(selected.createdAt)} · last seen{" "} + + {formatLastSeen(selected.lastSeenAt).toLowerCase()} + + + + + + )} + + + + ); +} diff --git a/packages/react/src/pages/connect-integration.tsx b/packages/react/src/pages/connect-integration.tsx new file mode 100644 index 0000000000..e1a8c2bcba --- /dev/null +++ b/packages/react/src/pages/connect-integration.tsx @@ -0,0 +1,109 @@ +import { useEffect } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useAtomValue, useAtomRefresh } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { integrationAtom, integrationsOptimisticAtom } from "../api/atoms"; +import { ErrorState } from "../components/error-state"; +import { Skeleton } from "../components/skeleton"; +import { isAsyncResultLoading } from "../lib/async-result"; +import { useExecutorDocumentTitle } from "../lib/document-title"; + +// --------------------------------------------------------------------------- +// `/connect/` — the stable, shareable deep link that drops a +// person straight into the connect flow for ONE integration ("connect your +// Linear") instead of the dashboard. +// +// This page owns no connect UI of its own. It resolves the slug against the +// tenant's catalog and forwards into the integration detail page's existing +// account handoff (`?addAccount=1`), which is the same surface the detail +// page's own "Add connection" button opens — multi-method tab strips, OAuth +// popups, existing-connection lists and "add another account" all come for +// free, and stay correct as that flow evolves. +// +// Deliberately keyed on the INTEGRATION SLUG, not a plugin id: the slug is the +// catalog identity a customer can write down, whereas plugin routes are on +// their way out. The redirect is `replace: true` so the deep link does not +// linger in history and Back leaves the connect flow rather than bouncing. +// --------------------------------------------------------------------------- + +export function ConnectIntegrationPage(props: { readonly integrationSlug: string }) { + const { integrationSlug } = props; + const slug = IntegrationSlug.make(integrationSlug); + const integration = useAtomValue(integrationAtom(slug)); + const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); + const navigate = useNavigate(); + + useExecutorDocumentTitle("Connect"); + + // The catalog read is a client-side find over the integrations list, so an + // unknown slug resolves to Success(null) rather than a failure — the missing + // case is a not-found state, and only a genuine fetch failure is an error. + const resolved = AsyncResult.isSuccess(integration) ? integration.value : null; + const loading = isAsyncResultLoading(integration); + const failed = AsyncResult.isFailure(integration); + const missing = !loading && !failed && resolved === null; + + useEffect(() => { + if (resolved === null) return; + void navigate({ + to: "/{-$orgSlug}/integrations/$namespace", + params: { namespace: String(resolved.slug) }, + // The NUMBER 1, so the router serializes the canonical `?addAccount=1` + // that agent-generated handoff links already use (a string would be + // quoted as `%221%22`). + search: { tab: "accounts", addAccount: 1 }, + replace: true, + }); + }, [resolved, navigate]); + + if (failed) { + return ( + + + + ); + } + + if (missing) { + return ( + +
+

+ Unknown integration: {integrationSlug} +

+

+ This integration is not in your workspace. +

+ + Back to integrations + +
+
+ ); + } + + // Loading, or resolved and mid-redirect: the same quiet placeholder, so the + // hand-off never flashes an empty frame between catalog load and navigation. + return ( + +
+ + + +
+
+ ); +} + +function ConnectStateFrame(props: { readonly children: React.ReactNode }) { + return ( +
+
{props.children}
+
+ ); +} diff --git a/packages/react/src/pages/integration-detail.tsx b/packages/react/src/pages/integration-detail.tsx index cd61270a02..a84b30cba8 100644 --- a/packages/react/src/pages/integration-detail.tsx +++ b/packages/react/src/pages/integration-detail.tsx @@ -60,6 +60,10 @@ type ToolRow = { export function IntegrationDetailPage(props: { namespace: string; tab?: IntegrationDetailSearchTab; + /** Route-validated `addAccount=1`: open the add-connection flow on arrival. + * Set by the `/connect/` deep link, which navigates client-side and so + * cannot rely on the raw location search below. */ + addAccount?: boolean; }) { const { namespace } = props; const slug = IntegrationSlug.make(namespace); @@ -122,9 +126,11 @@ export function IntegrationDetailPage(props: { const canRefresh = integrationData?.canRefresh ?? false; const canRemove = integrationData?.canRemove ?? false; const urlAccountHandoff = useMemo(() => { - if (locationSearch.length === 0) return null; const search = new URLSearchParams(locationSearch); - if (search.get("addAccount") !== "1") return null; + // The route-validated flag and the raw `addAccount=1` are the same request; + // either one opens the flow. The extra prefill fields below are read from + // the raw search, which is populated on the full page loads that carry them. + if (!props.addAccount && search.get("addAccount") !== "1") return null; const owner = search.get("owner"); const template = search.get("template"); const label = search.get("label"); @@ -149,13 +155,13 @@ export function IntegrationDetailPage(props: { }; })(); return { - key: locationSearch, + key: `${String(props.addAccount)}:${locationSearch}`, ...(owner === "org" || owner === "user" ? { owner } : {}), ...(template != null && template.length > 0 ? { template } : {}), ...(label != null && label.length > 0 ? { label } : {}), ...(oauthClient !== undefined ? { oauthClient } : {}), }; - }, [locationSearch]); + }, [locationSearch, props.addAccount]); const accountHandoff = manualAccountHandoff ?? urlAccountHandoff; useEffect(() => { diff --git a/packages/react/src/routes/connect-deep-link.test.ts b/packages/react/src/routes/connect-deep-link.test.ts new file mode 100644 index 0000000000..aa72797684 --- /dev/null +++ b/packages/react/src/routes/connect-deep-link.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; + +import { CONSOLE_ROUTE_PATHS } from "../console-routes"; +import { isAddAccountRequested } from "./integrations.$namespace"; + +// Routing semantics of the `/connect/` deep link — the URL a +// customer hands to an end user ("connect your Linear"). The page component is +// exercised end-to-end in e2e/scenarios/connect-deep-link.test.ts; this locks +// the URL contract itself: which shapes match, what the params resolve to, and +// that the org-slug prefix round-trips. + +type RouteMatch = { readonly routeId: string; readonly params: Record }; +type SpikeRouter = { + matchRoutes(pathname: string): ReadonlyArray; + buildLocation(options: unknown): { readonly href: string }; +}; + +const CONNECT_ROUTE_ID = "/{-$orgSlug}/connect/$integrationSlug"; + +const make = (): SpikeRouter => { + const rootRoute = createRootRoute(); + const orgScope = createRoute({ getParentRoute: () => rootRoute, path: "/{-$orgSlug}" }); + const indexRoute = createRoute({ getParentRoute: () => orgScope, path: "/" }); + const connectRoute = createRoute({ + getParentRoute: () => orgScope, + path: "/connect/$integrationSlug", + }); + const nsRoute = createRoute({ + getParentRoute: () => orgScope, + path: "/integrations/$namespace", + }); + const routeTree = rootRoute.addChildren([ + orgScope.addChildren([indexRoute, connectRoute, nsRoute]), + ]); + // A test-local tree is not the package's registered console tree, so widen + // away the global Register types — this asserts runtime semantics. + // oxlint-disable-next-line executor/no-double-cast -- boundary: a test-local route tree must escape the package-global router Register types + return createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ["/"] }), + }) as unknown as SpikeRouter; +}; + +describe("connect deep link", () => { + it("is part of the shared console route contract", () => { + // Every host mounts the contract, so the deep link exists on cloud, + // self-host, cloudflare and local alike. + expect(CONSOLE_ROUTE_PATHS).toContain("/connect/$integrationSlug"); + }); + + it("matches the bare link a customer would generate", () => { + const matches = make().matchRoutes("/connect/linear"); + const match = matches.find((m) => m.routeId === CONNECT_ROUTE_ID); + expect(match?.params).toEqual({ integrationSlug: "linear" }); + }); + + it("matches the org-prefixed form without eating the slug", () => { + const matches = make().matchRoutes("/acme/connect/linear"); + const match = matches.find((m) => m.routeId === CONNECT_ROUTE_ID); + expect(match?.params).toEqual({ orgSlug: "acme", integrationSlug: "linear" }); + }); + + it("keeps slugs that look like other console words intact", () => { + // The integration slug is a free-form catalog id; "tools" or "integrations" + // as a slug must still land on connect, not on those pages. + for (const slug of ["tools", "integrations", "connect"]) { + const match = make() + .matchRoutes(`/connect/${slug}`) + .find((m) => m.routeId === CONNECT_ROUTE_ID); + expect(match?.params).toEqual({ integrationSlug: slug }); + } + }); + + it("builds the link with and without an org slug", () => { + const router = make(); + expect( + router.buildLocation({ to: CONNECT_ROUTE_ID, params: { integrationSlug: "linear" } }).href, + ).toBe("/connect/linear"); + expect( + router.buildLocation({ + to: CONNECT_ROUTE_ID, + params: { orgSlug: "acme", integrationSlug: "linear" }, + }).href, + ).toBe("/acme/connect/linear"); + }); + + it("forwards into the integration's existing connect flow", () => { + // The page redirects here rather than rendering its own connect UI; the + // handoff is carried as validated search so it survives client navigation. + // The number 1 keeps the canonical `?addAccount=1` that agent-generated + // handoff links already use — a string would serialize as `%221%22`. + const href = make().buildLocation({ + to: "/{-$orgSlug}/integrations/$namespace", + params: { namespace: "linear" }, + search: { tab: "accounts", addAccount: 1 }, + }).href; + expect(href).toBe("/integrations/linear?tab=accounts&addAccount=1"); + }); +}); + +describe("addAccount flag", () => { + it("accepts every spelling the two link sources produce", () => { + // A full page load parses `?addAccount=1` to the number; a client-side + // navigation may carry the string. Both mean "open the connect flow". + expect(isAddAccountRequested(1)).toBe(true); + expect(isAddAccountRequested("1")).toBe(true); + expect(isAddAccountRequested(true)).toBe(true); + }); + + it("ignores anything else instead of failing the page", () => { + for (const value of [undefined, null, 0, "0", "", "banana", {}]) { + expect(isAddAccountRequested(value)).toBe(false); + } + }); +}); diff --git a/packages/react/src/routes/connect.$integrationSlug.tsx b/packages/react/src/routes/connect.$integrationSlug.tsx new file mode 100644 index 0000000000..f0d9756a23 --- /dev/null +++ b/packages/react/src/routes/connect.$integrationSlug.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ConnectIntegrationPage } from "../pages/connect-integration"; + +export const Route = createFileRoute("/{-$orgSlug}/connect/$integrationSlug")({ + component: () => { + const { integrationSlug } = Route.useParams(); + return ; + }, +}); diff --git a/packages/react/src/routes/integrations.$namespace.tsx b/packages/react/src/routes/integrations.$namespace.tsx index 577a953360..03b7403dc1 100644 --- a/packages/react/src/routes/integrations.$namespace.tsx +++ b/packages/react/src/routes/integrations.$namespace.tsx @@ -4,21 +4,40 @@ import { createFileRoute } from "@tanstack/react-router"; import { IntegrationDetailPage } from "../pages/integration-detail"; import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs"; +// `addAccount=1` opens the add-connection flow on arrival. It is declared here +// (rather than only read off `window.location`) because unlisted keys do not +// survive validation: a CLIENT-SIDE navigation carrying the handoff — the +// `/connect/` deep link — would otherwise have it stripped before the +// page could see it. The richer handoff fields stay URL-only; they arrive on +// full page loads from agent-generated links. +// +// Typed as unknown and coerced, not as a literal, for two reasons. A URL flag +// must never hard-fail a page: a hand-edited `?addAccount=banana` should be +// ignored, not rendered as a validation error. And the router's search +// serializer quotes a string "1" (`addAccount=%221%22`) while leaving the +// number 1 bare, so accepting both is what keeps ONE canonical URL — +// `?addAccount=1` — shared by deep links and agent-generated handoff links. const SearchParams = Schema.toStandardSchemaV1( Schema.Struct({ tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])), + addAccount: Schema.optional(Schema.Unknown), }), ); +/** The truthy spellings of the `addAccount` URL flag. */ +export const isAddAccountRequested = (value: unknown): boolean => + value === 1 || value === "1" || value === true; + export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({ validateSearch: SearchParams, component: () => { const { namespace } = Route.useParams(); - const { tab } = Route.useSearch(); + const { tab, addAccount } = Route.useSearch(); return ( ); }, diff --git a/packages/react/src/routes/routeTree.gen.ts b/packages/react/src/routes/routeTree.gen.ts index 878b5cedaf..a636a8d51e 100644 --- a/packages/react/src/routes/routeTree.gen.ts +++ b/packages/react/src/routes/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './__root' import { Route as DotIndexRouteImport } from './index' +import { Route as DotUsersRouteImport } from './users' import { Route as DotToolsRouteImport } from './tools' import { Route as DotToolkitsRouteImport } from './toolkits' import { Route as DotSecretsRouteImport } from './secrets' @@ -17,6 +18,7 @@ import { Route as DotPoliciesRouteImport } from './policies' import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' +import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug' import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$' import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey' @@ -25,6 +27,11 @@ const DotIndexRoute = DotIndexRouteImport.update({ path: '/{-$orgSlug}/', getParentRoute: () => rootRouteImport, } as any) +const DotUsersRoute = DotUsersRouteImport.update({ + id: '/{-$orgSlug}/users', + path: '/{-$orgSlug}/users', + getParentRoute: () => rootRouteImport, +} as any) const DotToolsRoute = DotToolsRouteImport.update({ id: '/{-$orgSlug}/tools', path: '/{-$orgSlug}/tools', @@ -62,6 +69,12 @@ const DotIntegrationsDotnamespaceRoute = path: '/{-$orgSlug}/integrations/$namespace', getParentRoute: () => rootRouteImport, } as any) +const DotConnectDotintegrationSlugRoute = + DotConnectDotintegrationSlugRouteImport.update({ + id: '/{-$orgSlug}/connect/$integrationSlug', + path: '/{-$orgSlug}/connect/$integrationSlug', + getParentRoute: () => rootRouteImport, + } as any) const DotPluginsDotpluginIdDotsplatRoute = DotPluginsDotpluginIdDotsplatRouteImport.update({ id: '/{-$orgSlug}/plugins/$pluginId/$', @@ -80,7 +93,9 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute + '/{-$orgSlug}/users': typeof DotUsersRoute '/{-$orgSlug}/': typeof DotIndexRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute @@ -92,7 +107,9 @@ export interface FileRoutesByTo { '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute + '/{-$orgSlug}/users': typeof DotUsersRoute '/{-$orgSlug}': typeof DotIndexRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute @@ -105,7 +122,9 @@ export interface FileRoutesById { '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute + '/{-$orgSlug}/users': typeof DotUsersRoute '/{-$orgSlug}/': typeof DotIndexRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute @@ -119,7 +138,9 @@ export interface FileRouteTypes { | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' + | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -131,7 +152,9 @@ export interface FileRouteTypes { | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' + | '/{-$orgSlug}/users' | '/{-$orgSlug}' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -143,7 +166,9 @@ export interface FileRouteTypes { | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' + | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' @@ -156,7 +181,9 @@ export interface RootRouteChildren { DotSecretsRoute: typeof DotSecretsRoute DotToolkitsRoute: typeof DotToolkitsRouteWithChildren DotToolsRoute: typeof DotToolsRoute + DotUsersRoute: typeof DotUsersRoute DotIndexRoute: typeof DotIndexRoute + DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute @@ -172,6 +199,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotIndexRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/users': { + id: '/{-$orgSlug}/users' + path: '/{-$orgSlug}/users' + fullPath: '/{-$orgSlug}/users' + preLoaderRoute: typeof DotUsersRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/tools': { id: '/{-$orgSlug}/tools' path: '/{-$orgSlug}/tools' @@ -221,6 +255,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotIntegrationsDotnamespaceRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/connect/$integrationSlug': { + id: '/{-$orgSlug}/connect/$integrationSlug' + path: '/{-$orgSlug}/connect/$integrationSlug' + fullPath: '/{-$orgSlug}/connect/$integrationSlug' + preLoaderRoute: typeof DotConnectDotintegrationSlugRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -255,7 +296,9 @@ const rootRouteChildren: RootRouteChildren = { DotSecretsRoute: DotSecretsRoute, DotToolkitsRoute: DotToolkitsRouteWithChildren, DotToolsRoute: DotToolsRoute, + DotUsersRoute: DotUsersRoute, DotIndexRoute: DotIndexRoute, + DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute, DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute, DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute, DotIntegrationsDotaddDotpluginKeyRoute: diff --git a/packages/react/src/routes/users.tsx b/packages/react/src/routes/users.tsx new file mode 100644 index 0000000000..b02ee0992f --- /dev/null +++ b/packages/react/src/routes/users.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { AdminUsersPage } from "../pages/admin-users"; + +export const Route = createFileRoute("/{-$orgSlug}/users")({ + component: () => , +}); diff --git a/scripts/check-route-trees.ts b/scripts/check-route-trees.ts new file mode 100644 index 0000000000..e4f806aec2 --- /dev/null +++ b/scripts/check-route-trees.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env bun +/** + * Asserts that every committed `routeTree.gen.ts` matches what its generator + * produces from the current route contract. + * + * The route trees are GENERATED (`bun run routes:gen` in each package, from + * `tsr.routes.ts` / `consoleRoutes()`) but COMMITTED, because the build and the + * type-level `Link to=...` checking both read them off disk. That makes them a + * classic drift surface: change the route contract, forget to regenerate, and + * the committed tree still compiles — the routes it lacks simply don't exist at + * runtime. + * + * Nothing else catches it. The generated files open with a suppression pragma + * that opts them out of type checking entirely, so `typecheck` cannot see a + * missing route; lint doesn't read them semantically; and unit tests import + * the very tree they are drifting against. This bug class has already escaped + * once and shipped a live 404 for a route that was in the contract but not in + * the committed tree. + * + * So: regenerate every tree into place, diff against git, and restore. The + * generators are pure functions of the checked-in route config, so a clean + * checkout is a no-op and any diff is real drift. + * + * Usage: + * bun run scripts/check-route-trees.ts + */ +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dir, ".."); + +/** + * Every package with a committed route tree, as (workspace dir, tree path) + * pairs. Each dir has a `routes:gen` script; the tree is the file it writes. + */ +const TREES = [ + { dir: "packages/react", tree: "packages/react/src/routes/routeTree.gen.ts" }, + { dir: "packages/app", tree: "packages/app/src/routeTree.gen.ts" }, + { dir: "apps/cloud", tree: "apps/cloud/src/routeTree.gen.ts" }, + { dir: "apps/host-selfhost", tree: "apps/host-selfhost/web/routeTree.gen.ts" }, + { dir: "apps/host-cloudflare", tree: "apps/host-cloudflare/web/routeTree.gen.ts" }, +] as const; + +const run = (command: string, args: readonly string[], cwd: string) => + spawnSync(command, [...args], { cwd, encoding: "utf8" }); + +/** + * Compare on ROUTE CONTENT, not byte-for-byte. + * + * Two generators write these files: each package's `routes:gen` script, and the + * TanStack vite plugin during `dev`. They agree on every route, import, and + * type — they disagree only on blank lines in the trailing `declare module` + * block. Comparing raw bytes would therefore fail on any checkout where a dev + * server had run, which is a false alarm about formatting, not the drift this + * guards (a route present in the contract and missing from the tree). Blank + * lines cannot encode a route, so dropping them loses no signal. + */ +const routeContent = (source: string): string => + source + .split("\n") + .filter((line) => line.trim() !== "") + .join("\n"); + +// Snapshot the committed content so a failed run leaves the checkout exactly as +// it found it — this script must be safe to run on a dirty working tree. +const before = new Map(); +for (const { tree } of TREES) { + before.set(tree, readFileSync(resolve(repoRoot, tree), "utf8")); +} + +const restore = () => { + for (const [tree, content] of before) writeFileSync(resolve(repoRoot, tree), content); +}; + +/** The lines each side has and the other does not — enough to name the route + * that drifted, without pulling in a diff library for a CI guard. */ +const lineDiff = (committed: string, regenerated: string): string => { + const committedLines = new Set(committed.split("\n")); + const regeneratedLines = new Set(regenerated.split("\n")); + const missing = [...regeneratedLines].filter((line) => !committedLines.has(line)); + const extra = [...committedLines].filter((line) => !regeneratedLines.has(line)); + const show = (label: string, lines: readonly string[]) => + lines.length === 0 + ? [] + : [` ${label}:`, ...lines.slice(0, 20).map((line) => ` ${line}`)]; + return [ + ...show("in the regenerated tree but NOT committed (missing routes)", missing), + ...show("committed but NOT regenerated (stale routes)", extra), + ].join("\n"); +}; + +type Failure = { readonly tree: string; readonly detail: string }; +const failures: Failure[] = []; + +for (const { dir, tree } of TREES) { + const generated = run("bun", ["run", "routes:gen"], resolve(repoRoot, dir)); + if (generated.status !== 0) { + failures.push({ + tree, + detail: `\`bun run routes:gen\` failed in ${dir}:\n${generated.stderr || generated.stdout}`, + }); + continue; + } + const after = readFileSync(resolve(repoRoot, tree), "utf8"); + const committed = routeContent(before.get(tree) ?? ""); + const regenerated = routeContent(after); + if (regenerated !== committed) { + // Show the actual drift rather than just naming the file: what changed is + // usually the whole answer (a route added to the contract, or removed). + // Diffed against the SNAPSHOT rather than via `git diff`, so this reports + // correctly even when the committed file was already dirty. + failures.push({ + tree, + detail: `regenerating produced a different tree:\n${lineDiff(committed, regenerated)}`, + }); + } +} + +restore(); + +if (failures.length > 0) { + const lines = failures.map((f) => ` - ${f.tree}: ${f.detail}`).join("\n\n"); + console.error( + `\nRoute-tree check FAILED (${failures.length} tree(s) out of date):\n${lines}\n\n` + + "Cause: a committed routeTree.gen.ts does not match what its generator\n" + + "produces from the current route contract — the contract changed and the\n" + + "tree was not regenerated. These files opt out of type checking, so the\n" + + "typecheck job cannot catch this; the app just serves a 404 for the\n" + + "missing route.\n\n" + + "Fix: regenerate and commit the trees:\n" + + TREES.map(({ dir }) => ` bun run --cwd ${dir} routes:gen`).join("\n") + + "\n", + ); + process.exit(1); +} + +console.log(`Route-tree check passed: ${TREES.length} generated tree(s) match their contract.`); From 2f09b13e0202e299808f527999e8a1d8bc84f136 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:35:16 -0700 Subject: [PATCH 018/328] Add one-off subject backfill from connection rows (#1491) --- apps/cloud/package.json | 2 + apps/cloud/scripts/backfill-subjects.ts | 66 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 apps/cloud/scripts/backfill-subjects.ts diff --git a/apps/cloud/package.json b/apps/cloud/package.json index c11b546ec5..6409dccb9c 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -30,6 +30,8 @@ "typecheck:slow": "tsc --noEmit", "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts", "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts", + "db:backfill-subjects:prod": "op run --env-file=.env.production -- bun run scripts/backfill-subjects.ts", + "db:backfill-subjects:dev": "op run --env-file=.env.op -- bun run scripts/backfill-subjects.ts", "routes:gen": "bun scripts/gen-routes.ts" }, "dependencies": { diff --git a/apps/cloud/scripts/backfill-subjects.ts b/apps/cloud/scripts/backfill-subjects.ts new file mode 100644 index 0000000000..a2ffa7f959 --- /dev/null +++ b/apps/cloud/scripts/backfill-subjects.ts @@ -0,0 +1,66 @@ +/* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */ +// --------------------------------------------------------------------------- +// One-off data backfill: mint `subject` rows for principals that predate the +// subject table (added by migration 0012, written only on sightings from then +// on). +// +// bun run db:backfill-subjects:prod # op run --env-file=.env.production +// bun run db:backfill-subjects:dev # against the local PGlite dev db +// +// Source of truth is the `connection` table: before 0012, "which users exist +// under this tenant" was answerable only through connection rows, so a distinct +// (tenant, subject) there — excluding the org sentinel '' — is exactly the set +// of users the admin console under-reports until their next request. +// +// created_at ← the tenant+subject's oldest connection, the closest honest +// answer to "when did this user first appear". +// last_seen_at ← NULL. The column means "seen on a request", no request has +// been recorded, and the console renders NULL as "Never" — +// which is true since the sighting code deployed. +// +// Idempotent — ON CONFLICT on the (tenant, external_id) unique index leaves +// rows the runtime has since minted (or a prior run inserted) untouched, so +// re-running is safe and never rewinds a live row's fields. +// Pass --dry-run to print the plan without writing. +// --------------------------------------------------------------------------- + +import postgres from "postgres"; +import { createId } from "@executor-js/fumadb/cuid"; + +const dryRun = process.argv.includes("--dry-run"); + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + console.error("DATABASE_URL is not set"); + process.exit(1); +} + +const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" }); + +const rows = await sql<{ tenant: string; subject: string; first_connection_at: Date }[]>` + SELECT tenant, subject, MIN(created_at) AS first_connection_at + FROM connection + WHERE subject <> '' + GROUP BY tenant, subject + ORDER BY tenant, subject +`; +console.log(`${rows.length} (tenant, subject) pair(s) with connections`); + +let inserted = 0; +for (const row of rows) { + console.log( + `${row.tenant} ${row.subject} first connection ${row.first_connection_at.toISOString()}`, + ); + if (dryRun) continue; + const result = await sql` + INSERT INTO subject (row_id, tenant, external_id, created_at, last_seen_at, status) + VALUES (${createId()}, ${row.tenant}, ${row.subject}, ${row.first_connection_at}, NULL, NULL) + ON CONFLICT (tenant, external_id) DO NOTHING + `; + inserted += result.count; +} + +console.log( + dryRun ? `dry run — would insert up to ${rows.length} row(s)` : `inserted ${inserted} row(s)`, +); +await sql.end(); From b7c2333ddc34b364bfb6c608c267192898ec4780 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:22:35 -0700 Subject: [PATCH 019/328] Confirm before removing a connection (#1496) --- .../connection-remove-confirm.test.ts | 121 ++++++++++++++++++ .../react/src/components/accounts-section.tsx | 56 +++++++- 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 e2e/scenarios/connection-remove-confirm.test.ts diff --git a/e2e/scenarios/connection-remove-confirm.test.ts b/e2e/scenarios/connection-remove-confirm.test.ts new file mode 100644 index 0000000000..52877548f1 --- /dev/null +++ b/e2e/scenarios/connection-remove-confirm.test.ts @@ -0,0 +1,121 @@ +// Cross-target (browser): removing a connection asks for confirmation first. +// Remove is destructive and irreversible (credentials are immutable, so a +// removed connection cannot be restored — only re-created), so the row's +// Remove menu item must open a confirm dialog rather than firing the +// mutation directly. Cancel keeps the connection; confirming removes it for +// real (asserted through the API, not just the list). +import { randomBytes } from "node:crypto"; + +import { Effect } from "effect"; +import { expect } from "@effect/vitest"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +/** Minimal apiKey-authenticated spec — a connection can bind to it; the single + * operation is never invoked here. */ +const pingSpec = JSON.stringify({ + openapi: "3.0.3", + info: { title: "Ping API", version: "1.0.0" }, + paths: { + "/ping": { + get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, + }, + }, +}); + +scenario( + "Connections (UI) · Remove asks for confirmation; cancel keeps, confirm removes", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + + const slug = IntegrationSlug.make(`rm-confirm-${randomBytes(4).toString("hex")}`); + const name = ConnectionName.make("main"); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: pingSpec }, + slug, + baseUrl: "http://127.0.0.1:59999", // never contacted + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + name, + integration: slug, + template: AuthTemplateSlug.make("apiKey"), + value: `key_${randomBytes(8).toString("hex")}`, + }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + const connections = page.locator("section").filter({ + has: page.getByRole("heading", { level: 3, name: "Connections" }), + }); + const row = connections.getByText("main", { exact: true }); + const menuTrigger = connections.locator('button[aria-haspopup="menu"]'); + const confirm = page.getByRole("alertdialog"); + + await step("Open the integration's connections", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await row.waitFor(); + }); + + await step("Remove asks for confirmation instead of firing", async () => { + await menuTrigger.click(); + await page.getByRole("menuitem", { name: "Remove" }).click(); + await confirm.getByText("Remove main?").waitFor(); + }); + + await step("Cancel keeps the connection", async () => { + await confirm.getByRole("button", { name: "Cancel" }).click(); + await confirm.waitFor({ state: "detached" }); + await row.waitFor(); + }); + + await step("Confirming actually removes it", async () => { + await menuTrigger.click(); + await page.getByRole("menuitem", { name: "Remove" }).click(); + await confirm.getByRole("button", { name: "Remove connection" }).click(); + await confirm.waitFor({ state: "detached" }); + await row.waitFor({ state: "detached" }); + }); + }); + + // Cancel left the removal un-fired and confirm fired it for real: the + // connection is gone from the API, not merely from the rendered list. + const remaining = yield* client.connections.list({ query: { integration: slug } }); + expect( + remaining.map((connection) => String(connection.name)), + "the removed connection is gone from the API", + ).not.toContain(String(name)); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ params: { owner: "org", integration: slug, name } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), +); diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index 7cbdf6d1e4..cc21e4fd36 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -30,6 +30,16 @@ import { useOAuthPopupFlow } from "../plugins/oauth-sign-in"; import { AddAccountModal } from "./add-account-modal"; import { ConnectionEditSheet } from "./metadata-edit-sheet"; import type { CreateCustomMethod } from "./add-custom-method-modal"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "./alert-dialog"; import { Badge } from "./badge"; import { Button } from "./button"; import { @@ -60,6 +70,16 @@ import { const OWNERS: readonly Owner[] = ["org", "user"]; +// The confirm dialog names the connection the way the row does — stored +// identity label first, connection name otherwise. (No live probe identity +// here; that state lives inside the row.) +const connectionDisplayLabel = (connection: Connection | null): string => { + if (connection === null) return "connection"; + return connection.identityLabel && connection.identityLabel.length > 0 + ? connection.identityLabel + : String(connection.name); +}; + function AccountRow(props: { readonly connection: Connection; /** The integration declares scopes this connection was not granted — it must @@ -212,6 +232,10 @@ function OwnerAccounts(props: { }) { const { integration, owner } = props; const connections = useAtomValue(connectionsForIntegrationAtom({ integration, owner })); + // Removal confirms in a dialog. State lives here (not in the row) because the + // Remove menu item closes its dropdown on click, which would unmount a dialog + // nested inside it — so the row only nominates the connection to remove. + const [removingConnection, setRemovingConnection] = useState(null); const doRemove = useAtomSet(removeConnectionOptimistic(owner), { mode: "promiseExit", }); @@ -324,6 +348,7 @@ function OwnerAccounts(props: { }; const handleRemove = async (connection: Connection) => { + setRemovingConnection(null); const exit = await doRemove({ params: { owner: connection.owner, @@ -354,10 +379,39 @@ function OwnerAccounts(props: { showOwnerLabel={props.showOwnerLabels} onEdit={() => props.onEdit(connection)} onReconnect={() => void handleReconnect(connection)} - onRemove={() => void handleRemove(connection)} + onRemove={() => setRemovingConnection(connection)} /> ))} + { + if (!open) setRemovingConnection(null); + }} + > + + + + Remove {connectionDisplayLabel(removingConnection)}? + + + Tools using this connection lose access. This cannot be undone; reconnecting later + creates a new connection. + + + + Cancel + { + if (removingConnection !== null) void handleRemove(removingConnection); + }} + > + Remove connection + + + + ); } From f78b8caae443f08a1244cef5ea74fefa8a5df253 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:40:42 -0700 Subject: [PATCH 020/328] Trace the OAuth lifecycle: token requests, connects, and health checks (#1485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth domain layer had no spans. The token POST also bypasses Effect's HttpClient (oauth4webapi drives the raw fetch), so not even an http.client span existed underneath — AS latency and refusal rates were unmeasurable, and health verdicts were only obtainable by querying the database. - executor.oauth.token_request on all three grants: grant type, token hostname, client auth method, and the AS's RFC 6749 error code on failure. Hostname only, never the full URL. - executor.oauth.complete around the code redemption: integration, client slug, whether the token host was rebound to a regional endpoint, and the connection's freshness facts at birth (refresh token present, expiry advertised, scope shortfall counts). - executor.connection.health.check / executor.connection.validate with the verdict, its source (cache, probe, credential-only, no-capability), and http status. mcp.plugin.check_health gets the same outcome stamp — its failures fold onto the success channel, so the span previously ended green on an expired credential. - The AS error code is now a typed oauthErrorCode field on CredentialResolutionError and a structured detail on the sandbox-facing failure, instead of surviving only inside message text. --- e2e/scenarios/oauth-refresh-rejected.test.ts | 5 ++ packages/core/execution/src/tool-invoker.ts | 8 ++ packages/core/sdk/src/errors.ts | 7 ++ packages/core/sdk/src/executor.ts | 88 ++++++++++++++++---- packages/core/sdk/src/oauth-helpers.ts | 67 ++++++++++++++- packages/core/sdk/src/oauth-service.ts | 49 +++++++++-- packages/plugins/mcp/src/sdk/plugin.ts | 18 +++- 7 files changed, 217 insertions(+), 25 deletions(-) diff --git a/e2e/scenarios/oauth-refresh-rejected.test.ts b/e2e/scenarios/oauth-refresh-rejected.test.ts index e71ec39226..a18c874b9e 100644 --- a/e2e/scenarios/oauth-refresh-rejected.test.ts +++ b/e2e/scenarios/oauth-refresh-rejected.test.ts @@ -118,6 +118,7 @@ type ToolEnvelope = { readonly details?: { readonly category?: string; readonly recovery?: Record; + readonly upstream?: { readonly details?: { readonly oauthErrorCode?: string } }; }; }; }; @@ -249,6 +250,10 @@ scenario( failure.error?.message ?? "", "the message carries the AS's RFC 6749 error code", ).toContain("invalid_request"); + expect( + failure.error?.details?.upstream?.details?.oauthErrorCode, + "the AS's code is ALSO a structured field, so agents and telemetry never parse the message for it", + ).toBe("invalid_request"); expect( failure.error?.message ?? "", "the message carries the AS's error description", diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 5c427148db..4da9251767 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -177,6 +177,7 @@ const credentialResolutionToolFailure = (input: { readonly label: string; readonly message: string; readonly reauthRequired?: boolean; + readonly oauthErrorCode?: string; }) => authToolFailure({ code: input.reauthRequired === true ? "oauth_reauth_required" : "oauth_refresh_failed", @@ -188,6 +189,12 @@ const credentialResolutionToolFailure = (input: { kind: "oauth", label: input.label, }, + // The AS's own RFC 6749 §5.2 verdict, when there was one — structured so + // the agent (and anyone reading the failure) can distinguish a dead grant + // from a misconfigured app without parsing the message. + ...(input.oauthErrorCode !== undefined + ? { upstream: { details: { oauthErrorCode: input.oauthErrorCode } } } + : {}), }); const bindingToolFailure = (value: unknown): ToolError | null => { @@ -310,6 +317,7 @@ export const makeExecutorToolInvoker = ( label: `${err.integration}.${err.owner}.${err.name}`, message: err.message, reauthRequired: err.reauthRequired, + oauthErrorCode: err.oauthErrorCode, }), ), ), diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index 7c5223cc2d..078aeaca4f 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -181,6 +181,13 @@ export class CredentialResolutionError extends Schema.TaggedErrorClass Effect.annotateCurrentSpan({ "executor.oauth.refresh.outcome": "ok" })), Effect.tapError((error: StorageFailure | CredentialResolutionError) => Effect.annotateCurrentSpan({ @@ -1870,7 +1868,12 @@ export const createExecutor = => + Effect.annotateCurrentSpan({ + "executor.health.status": result.status, + "executor.health.source": source, + ...(result.httpStatus !== undefined + ? { "executor.health.http_status": result.httpStatus } + : {}), + }); + const connectionCheckHealth = ( ref: ConnectionRef, options?: { @@ -3116,7 +3138,10 @@ export const createExecutor = => toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error))); +/** Trace one token-endpoint round trip. This is the ONLY place a token request + * can be observed: oauth4webapi drives the raw global `fetch`, not Effect's + * HttpClient, so no `http.client` span exists underneath — without this span + * the AS's latency and refusal rate are invisible. + * + * Attribute discipline: hostname (never the full URL — some providers carry + * tenant ids in the path), the grant literal, the auth method, and on failure + * the AS's RFC 6749 §5.2 code. Never the OAuth2Error message (it embeds the + * response URL and a body preview), never token or code material. */ +const withTokenRequestSpan = + (input: { + readonly grantType: "authorization_code" | "client_credentials" | "refresh_token"; + readonly tokenUrl: string; + readonly clientAuth: ClientAuthMethod | undefined; + readonly hasResource: boolean; + }) => +
(effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.tapError((error) => + Effect.annotateCurrentSpan({ + ...(error.error !== undefined ? { "executor.oauth.error_code": error.error } : {}), + }), + ), + Effect.withSpan("executor.oauth.token_request", { + attributes: { + "executor.oauth.grant_type": input.grantType, + "executor.oauth.token_host": hostnameForTelemetry(input.tokenUrl), + "executor.oauth.client_auth": input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, + "executor.oauth.has_resource": input.hasResource, + }, + }), + ); + +/** The hostname alone — a malformed URL yields "invalid" rather than leaking + * whatever string failed to parse. */ +const hostnameForTelemetry = (url: string): string => URL.parse(url)?.hostname ?? "invalid"; + // --------------------------------------------------------------------------- // oauth4webapi adapter helpers // --------------------------------------------------------------------------- @@ -614,7 +651,15 @@ export const exchangeAuthorizationCode = ( return await processTokenEndpointResponse(as, client, response); }, catch: (cause) => cause, - }).pipe(Effect.catch(failOAuth2WithHttpSummary)); + }).pipe( + Effect.catch(failOAuth2WithHttpSummary), + withTokenRequestSpan({ + grantType: "authorization_code", + tokenUrl: input.tokenUrl, + clientAuth: input.clientAuth, + hasResource: input.resource !== undefined, + }), + ); // --------------------------------------------------------------------------- // Exchange client credentials → tokens (RFC 6749 §4.4) @@ -669,7 +714,15 @@ export const exchangeClientCredentials = ( return tokenResponseFrom(result); }, catch: (cause) => cause, - }).pipe(Effect.catch(failOAuth2WithHttpSummary)); + }).pipe( + Effect.catch(failOAuth2WithHttpSummary), + withTokenRequestSpan({ + grantType: "client_credentials", + tokenUrl: input.tokenUrl, + clientAuth: input.clientAuth, + hasResource: input.resource !== undefined, + }), + ); // --------------------------------------------------------------------------- // Refresh access token @@ -740,7 +793,15 @@ export const refreshAccessToken = ( return tokenResponseFrom(result); }, catch: (cause) => cause, - }).pipe(Effect.catch(failOAuth2WithHttpSummary)); + }).pipe( + Effect.catch(failOAuth2WithHttpSummary), + withTokenRequestSpan({ + grantType: "refresh_token", + tokenUrl: input.tokenUrl, + clientAuth: input.clientAuth, + hasResource: input.resource !== undefined, + }), + ); // --------------------------------------------------------------------------- // Refresh-needed predicate diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index f28eac5da2..1db71cadfd 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -1283,6 +1283,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { clientOwnerFromPayload(sessionRow.payload) ?? (String(sessionRow.owner) as Owner), }; + // Annotate as soon as the session resolves the flow's identity, so even + // a completion that fails at the exchange still says WHOSE connect died. + yield* Effect.annotateCurrentSpan({ + "executor.integration": String(session.integration), + "executor.connection": String(session.name), + "executor.template": String(session.template), + "executor.oauth.client": String(session.clientSlug), + }); + // Expired sessions are not redeemable — drop + treat as not found. if (Number.isFinite(session.expiresAt) && session.expiresAt <= Date.now()) { yield* deleteSession(input.state); @@ -1368,9 +1377,24 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ), ); + // Everything a "why did this connect fail / where did it go" question + // needs, none of it secret: slugs, owner scope, and whether the token + // host was rebound to a regional endpoint (the Datadog multi-site path — + // a bug there previously shipped and was only diagnosable by hand). + // Deliberately absent: the code, the PKCE verifier, the token, the + // callback domain (can embed an org's private site), and identityLabel + // (resolves to an email). + yield* Effect.annotateCurrentSpan({ + "executor.oauth.token_host_rebound": tokenUrl !== client.tokenUrl, + }); + yield* deleteSession(input.state); return connection; - }); + }).pipe( + Effect.withSpan("executor.oauth.complete", { + attributes: { "executor.oauth.grant": "authorization_code" }, + }), + ); // ----------------------------------------------------------------------- // Mint the connection from a freshly exchanged token: store the access @@ -1416,6 +1440,24 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { } const oauthScope = recordedOAuthScope(token, requestedScopes); + const missingScopes = + client.grant === "authorization_code" + ? missingGrantedOAuthScopes(requestedScopes, oauthScope) + : []; + // The freshness facts of this connection AT BIRTH, on the enclosing + // span (executor.oauth.complete, or the reconnect path's request + // envelope). Every "why did this connection later go stale" question + // starts here: a partial grant fails later as oauth_scope_insufficient + // in an unrelated trace; no refresh token means the first expiry is + // terminal; no advertised expiry means only the reactive 401 path can + // ever refresh it. Counts and booleans only — scope VALUES can encode + // customer resource names on some providers. + yield* Effect.annotateCurrentSpan({ + "executor.oauth.scope_requested_count": requestedScopes.length, + "executor.oauth.scope_missing_count": missingScopes.length, + "executor.oauth.has_refresh_token": token.refresh_token !== undefined, + "executor.oauth.has_advertised_expiry": typeof token.expires_in === "number", + }); return yield* deps.mintOAuthConnection({ owner: target.owner, name: target.name, @@ -1436,10 +1478,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // non-resource scope from the token `scope` string, so preserve it when // the refresh token proves it was granted. oauthScope, - missingOAuthScopes: - client.grant === "authorization_code" - ? missingGrantedOAuthScopes(requestedScopes, oauthScope) - : [], + missingOAuthScopes: missingScopes, oauthTokenUrl, }); }); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 623c262a98..e3b7a6857e 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1555,7 +1555,23 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { detail: error.message, } satisfies HealthCheckResult), ), - Effect.withSpan("mcp.plugin.check_health"), + // Every failure above folds onto the SUCCESS channel, so without this + // the span ends green on an expired credential — a 401 auth wall and a + // healthy dial were indistinguishable in traces. + Effect.tap((result) => + Effect.annotateCurrentSpan({ + "mcp.health.status": result.status, + ...("httpStatus" in result && result.httpStatus !== undefined + ? { "mcp.health.http_status": result.httpStatus } + : {}), + }), + ), + Effect.withSpan("mcp.plugin.check_health", { + attributes: { + "executor.integration": String(credential.integration), + "mcp.connection.name": String(credential.connection), + }, + }), ), describeAuthMethods: describeMcpAuthMethods, From 54df2e3e99509008759269b27484ee6581ce8827 Mon Sep 17 00:00:00 2001 From: David Rossiter <123392630+davidwrossiter@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:54:13 +0100 Subject: [PATCH 021/328] Fix GraphQL introspection health diagnostics (#1483) * fix graphql introspection health diagnostics * review fixes: scrub credential values from health details, fix auth classifier, unify introspection failure classification, surface retry failures --------- Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> --- .changeset/quiet-graphs-report.md | 5 + .../graphql-introspection-health.test.ts | 134 +++++++++ packages/core/sdk/src/core-tools.ts | 22 +- packages/core/sdk/src/executor.test.ts | 74 ++++- packages/plugins/graphql/src/sdk/errors.ts | 12 + .../plugins/graphql/src/sdk/introspect.ts | 15 +- .../plugins/graphql/src/sdk/plugin.test.ts | 225 ++++++++++++++ packages/plugins/graphql/src/sdk/plugin.ts | 283 ++++++++++++++++-- .../react/src/components/accounts-section.tsx | 13 +- .../src/components/add-account-modal.tsx | 72 +++-- packages/react/src/components/tool-detail.tsx | 23 +- packages/react/src/components/tool-tree.tsx | 6 +- .../react/src/pages/integration-detail.tsx | 110 ++++++- 13 files changed, 927 insertions(+), 67 deletions(-) create mode 100644 .changeset/quiet-graphs-report.md create mode 100644 e2e/scenarios/graphql-introspection-health.test.ts diff --git a/.changeset/quiet-graphs-report.md b/.changeset/quiet-graphs-report.md new file mode 100644 index 0000000000..6fdd329e64 --- /dev/null +++ b/.changeset/quiet-graphs-report.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +**Fix: GraphQL connections now reject credentials when schema introspection fails and show actionable tool sync diagnostics** diff --git a/e2e/scenarios/graphql-introspection-health.test.ts b/e2e/scenarios/graphql-introspection-health.test.ts new file mode 100644 index 0000000000..185ec36653 --- /dev/null +++ b/e2e/scenarios/graphql-introspection-health.test.ts @@ -0,0 +1,134 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { connectEmulator } from "@executor-js/emulate"; +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; +import { variable } from "@executor-js/sdk/http-auth"; + +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const api = composePluginApi([graphqlHttpPlugin()] as const); +const unique = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`; + +scenario( + "GraphQL · failed introspection blocks connection creation with an actionable error", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + const slug = unique("graphql_health"); + const emulatorBaseUrl = yield* createEmulatorInstance("github", "graphql-health"); + const emulator = yield* Effect.promise(() => + connectEmulator({ baseUrl: emulatorBaseUrl, service: "github" }), + ); + + yield* Effect.promise(() => + emulator.faults.arm({ + match: { method: "POST", pathPattern: "/graphql" }, + response: { status: 401, body: { message: "Bad credentials" } }, + times: 10, + }), + ); + + yield* client.graphql.addIntegration({ + payload: { + endpoint: `${emulatorBaseUrl}/graphql`, + slug, + name: "GraphQL health", + authenticationTemplate: [ + { + slug: "header", + type: "apiKey", + headers: { Authorization: [variable("token")] }, + }, + ], + }, + }); + + yield* Effect.gen(function* () { + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the connection flow", async () => { + await page.goto(`/integrations/${slug}?addAccount=1&owner=org&template=header`, { + waitUntil: "networkidle", + }); + await page.getByRole("heading", { name: /Add connection · GraphQL health/ }).waitFor(); + }); + + await step("Submit a credential rejected during schema introspection", async () => { + const dialog = page.getByRole("dialog", { + name: /Add connection · GraphQL health/, + }); + await dialog.getByRole("textbox", { name: "Authorization" }).fill("invalid-token"); + await dialog.getByRole("button", { name: "Continue" }).click(); + + const alert = dialog.getByRole("alert"); + await alert.waitFor(); + const message = await alert.textContent(); + expect(message).toContain("The endpoint rejected the credential with HTTP 401."); + expect(message).toContain("Check the credential and selected authentication method."); + await dialog.getByText("Step 1 of 2").waitFor(); + expect( + await page.getByText("No connections yet").count(), + "the rejected credential is not saved", + ).toBe(1); + }); + }); + + // The low-level API can still import an existing credential reference + // without the browser's preflight. This models connections created before + // the fix and proves their failed tool sync is no longer a silent zero. + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("legacy"), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("header"), + value: "invalid-token", + }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + await step("A failed existing connection explains the empty tool catalogue", async () => { + await page.goto(`/integrations/${slug}?tab=tools`, { waitUntil: "networkidle" }); + await page.getByText("Connection rejected", { exact: true }).first().waitFor(); + await page + .getByText("The endpoint rejected the credential with HTTP 401.", { + exact: false, + }) + .waitFor(); + await page.getByRole("button", { name: "Check and sync tools" }).waitFor(); + }); + + await step("The account row carries the same actionable health verdict", async () => { + await page.getByRole("tab", { name: "Accounts" }).click(); + await page.getByText("Expired", { exact: true }).waitFor(); + await page + .getByText("Check the credential and selected authentication method.", { + exact: false, + }) + .waitFor(); + }); + }); + }).pipe( + Effect.ensuring( + Effect.all( + [ + client.integrations + .remove({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + Effect.promise(() => emulator.faults.clear()).pipe(Effect.ignore), + ], + { concurrency: "unbounded" }, + ), + ), + ); + }), +); diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 085164e8b3..2b0ef306e9 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -22,6 +22,7 @@ import { type Owner, } from "./ids"; import { definePlugin, tool, type StaticToolSchema } from "./plugin"; +import { HealthCheckResult } from "./health-check"; import { ToolPolicyActionSchema } from "./policies"; import type { Tool } from "./tool"; @@ -76,6 +77,7 @@ const ConnectionOutput = Schema.Struct({ oauthClient: Schema.NullOr(Schema.String), oauthClientOwner: Schema.NullOr(OwnerSchema), oauthScope: Schema.NullOr(Schema.String), + lastHealth: Schema.NullOr(HealthCheckResult), }); const ConnectionsListInput = Schema.Struct({ @@ -102,6 +104,7 @@ const ConnectionListItem = Schema.Struct({ oauthClientOwner: Schema.NullOr(OwnerSchema), oauthScopeCount: Schema.NullOr(Schema.Number), oauthScope: Schema.optional(Schema.NullOr(Schema.String)), + lastHealth: Schema.NullOr(HealthCheckResult), }); const ConnectionsListOutput = Schema.Struct({ connections: Schema.Array(ConnectionListItem), @@ -171,6 +174,7 @@ const ToolOutput = Schema.Struct({ }); const ConnectionsRefreshOutput = Schema.Struct({ tools: Schema.Array(ToolOutput), + lastHealth: Schema.NullOr(HealthCheckResult), }); const RemovedOutput = Schema.Struct({ removed: Schema.Boolean }); @@ -373,6 +377,7 @@ const connectionToOutput = (connection: Connection) => ({ oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient), oauthClientOwner: connection.oauthClientOwner ?? null, oauthScope: connection.oauthScope ?? null, + lastHealth: connection.lastHealth ?? null, }); /** Number of space-separated grants in an `oauthScope` string, or null when @@ -396,6 +401,7 @@ const connectionToListItem = (connection: Connection, verbose: boolean) => ({ oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient), oauthClientOwner: connection.oauthClientOwner ?? null, oauthScopeCount: oauthScopeCount(connection.oauthScope), + lastHealth: connection.lastHealth ?? null, ...(verbose ? { oauthScope: connection.oauthScope ?? null } : {}), }); @@ -556,7 +562,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "connections.list", description: - "List saved connections (the credential for one integration). Never returns the credential value. Optionally filter by integration or owner. OAuth scopes are summarized as `oauthScopeCount` by default; pass `verbose: true` to include the full `oauthScope` grant string per connection.", + "List saved connections and their last health verdict. Never returns credential values. Optionally filter by integration or owner. OAuth scopes are summarized as `oauthScopeCount` by default; pass `verbose: true` to include the full `oauthScope` grant string per connection.", inputSchema: ConnectionsListInputStd, outputSchema: ConnectionsListOutputStd, execute: (input: typeof ConnectionsListInput.Type, { ctx }) => @@ -627,7 +633,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "connections.refresh", description: - "Re-run an integration's tool production for a saved connection, replacing that connection's persisted tools.", + "Re-run an integration's tool production for a saved connection. Returns the tools and the resulting health verdict so an empty catalog is distinguishable from a failed sync.", inputSchema: ConnectionRefInputStd, outputSchema: ConnectionsRefreshOutputStd, // Refresh replaces a connection's persisted tool set; for a mutable @@ -636,9 +642,15 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { // `sources.refresh`. annotations: { requiresApproval: true }, execute: (input: typeof ConnectionRefInput.Type, { ctx }) => - Effect.map(ctx.connections.refresh(connectionRefFromInput(input)), (tools) => ({ - tools: tools.map(toolToOutput), - })), + Effect.gen(function* () { + const ref = connectionRefFromInput(input); + const tools = yield* ctx.connections.refresh(ref); + const connection = yield* ctx.connections.get(ref); + return { + tools: tools.map(toolToOutput), + lastHealth: connection?.lastHealth ?? null, + }; + }), }), // removed: tools.list — the cross-connection tool catalog is an // executor-surface read, not exposed on PluginCtx. diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index a7ec1272d3..664b4d8b9a 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -15,7 +15,7 @@ import { import { definePlugin } from "./plugin"; import type { CredentialProvider } from "./provider"; import { IntegrationDetectionResult } from "./types"; -import { makeTestExecutor } from "./testing"; +import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; // removed: v1 secret browser-handoff, source.configure, case-insensitive tool-id @@ -112,6 +112,25 @@ const demoPlugin = definePlugin(() => ({ }), }))(); +const diagnosticsPlugin = definePlugin(() => ({ + id: "diagnostics" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [], + incomplete: true, + incompleteReason: "Schema introspection was rejected", + }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: IntegrationSlug.make("diagnostics"), + description: "Diagnostics", + config: {}, + }), + }), +}))(); + const detector = (id: string, confidence: IntegrationDetectionResult["confidence"]) => definePlugin(() => ({ id, @@ -269,6 +288,59 @@ describe("createExecutor", () => { }), ); + it.effect("surfaces failed tool sync diagnostics through connection tools", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, + coreTools: {}, + }); + yield* executor.diagnostics.seed(); + + yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.create"), + { + owner: "org", + name: "main", + integration: "diagnostics", + template: "none", + }, + { onElicitation: "accept-all" }, + ); + + const listed = yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.list"), + { integration: "diagnostics" }, + ); + expect(listed).toMatchObject({ + connections: [ + { + lastHealth: { + status: "degraded", + detail: "Tool sync failing: Schema introspection was rejected", + }, + }, + ], + }); + + const refreshed = yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.refresh"), + { + owner: "org", + name: "main", + integration: "diagnostics", + }, + { onElicitation: "accept-all" }, + ); + expect(refreshed).toMatchObject({ + tools: [], + lastHealth: { + status: "degraded", + detail: "Tool sync failing: Schema introspection was rejected", + }, + }); + }), + ); + it.effect("hands pasted credential entry to the web UI", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ diff --git a/packages/plugins/graphql/src/sdk/errors.ts b/packages/plugins/graphql/src/sdk/errors.ts index 2f0b61bbae..427da73b9f 100644 --- a/packages/plugins/graphql/src/sdk/errors.ts +++ b/packages/plugins/graphql/src/sdk/errors.ts @@ -6,6 +6,18 @@ export class GraphqlIntrospectionError extends Schema.TaggedErrorClass new GraphqlIntrospectionError({ message: "Failed to reach GraphQL endpoint", + reason: "network", }), ), ); @@ -285,6 +286,9 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( message: upstreamMessage ? `Introspection failed with status ${response.status}: ${upstreamMessage}` : `Introspection failed with status ${response.status}`, + status: response.status, + reason: "http", + ...(upstreamMessage ? { upstreamMessage } : {}), }); } @@ -294,6 +298,8 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( () => new GraphqlIntrospectionError({ message: `Failed to parse introspection response as JSON`, + status: response.status, + reason: "invalid-json", }), ), ); @@ -303,22 +309,29 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( () => new GraphqlIntrospectionError({ message: "Introspection response has an invalid shape", + status: response.status, + reason: "invalid-shape", }), ), ); if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) { - const upstreamMessage = firstUpstreamErrorMessage(json); + const upstreamMessage = upstreamTextMessage(firstUpstreamErrorMessage(json) ?? ""); return yield* new GraphqlIntrospectionError({ message: upstreamMessage ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}` : `Introspection returned ${json.errors.length} error(s)`, + status: response.status, + reason: "graphql-errors", + ...(upstreamMessage ? { upstreamMessage } : {}), }); } if (!json.data?.__schema) { return yield* new GraphqlIntrospectionError({ message: "Introspection response missing __schema", + status: response.status, + reason: "missing-schema", }); } diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 505f278797..7a9ef3161a 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -701,6 +701,228 @@ describe("graphqlPlugin real protocol server", () => { expect(names).toContain("mutation.setGreeting"); }), ); + + it.effect("validates an introspectable endpoint as healthy", () => + Effect.gen(function* () { + const server = yield* serveGreetingServer; + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.endpoint, + slug: "health_ok", + }); + expect( + yield* executor.integrations.healthCheck.candidates(IntegrationSlug.make("health_ok")), + ).toEqual([ + expect.objectContaining({ + operation: "__schema", + destructive: false, + }), + ]); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_ok"), + template: AuthTemplateSlug.make("none"), + value: "unused", + }); + + expect(result).toMatchObject({ + status: "healthy", + httpStatus: 200, + identity: "GraphQL schema: Query", + }); + }), + ); + + it.effect("rejects a credential blocked by an HTTP auth wall", () => + Effect.gen(function* () { + const server = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema(), + auth: { + validateAuthorization: (authorization) => Effect.succeed(authorization === "valid-token"), + }, + }); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.endpoint, + slug: "health_http_auth", + authenticationTemplate: [ + { + slug: "header", + kind: "apikey", + placements: [{ carrier: "header", name: "Authorization", prefix: "" }], + }, + ], + }); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_http_auth"), + template: AuthTemplateSlug.make("header"), + value: "wrong-token", + }); + + expect(result.status).toBe("expired"); + expect(result.httpStatus).toBe(401); + expect(result.detail).toContain("The endpoint rejected the credential with HTTP 401."); + expect(result.detail).toContain("Check the credential and selected authentication method."); + }), + ); + + it.effect("classifies an authorization GraphQL error as an expired credential", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe({ + errors: [{ message: "Unauthorized: invalid API key" }], + }), + ), + ); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.url("/graphql"), + slug: "health_graphql_auth", + }); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_graphql_auth"), + template: AuthTemplateSlug.make("none"), + value: "unused", + }); + + expect(result.status).toBe("expired"); + expect(result.httpStatus).toBe(200); + expect(result.detail).toContain("The endpoint rejected the credential."); + expect(result.detail).toContain("Upstream said: Unauthorized: invalid API key"); + }), + ); + + it.effect("classifies a bare auth GraphQL error without auth keywords near a boundary", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe({ + errors: [{ message: "Not authenticated" }], + }), + ), + ); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.url("/graphql"), + slug: "health_bare_auth", + }); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_bare_auth"), + template: AuthTemplateSlug.make("none"), + value: "unused", + }); + + expect(result.status).toBe("expired"); + expect(result.detail).toContain("Upstream said: Not authenticated"); + }), + ); + + it.effect("scrubs the probed credential value out of the health detail", () => + Effect.gen(function* () { + const secret = "sk_live_scrub_me"; + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe({ message: `Invalid API key ${secret}` }, { status: 401 }), + ), + ); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.url("/graphql"), + slug: "health_scrub", + authenticationTemplate: [ + { + slug: "header", + kind: "apikey", + placements: [{ carrier: "header", name: "Authorization", prefix: "" }], + }, + ], + }); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_scrub"), + template: AuthTemplateSlug.make("header"), + value: secret, + }); + + expect(result.status).toBe("expired"); + expect(result.detail).not.toContain(secret); + expect(result.detail).toContain("[redacted]"); + }), + ); + + it.effect("reports an invalid introspection response as degraded", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed(HttpServerResponse.jsonUnsafe({ data: {} })), + ); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.url("/graphql"), + slug: "health_invalid_shape", + }); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_invalid_shape"), + template: AuthTemplateSlug.make("none"), + value: "unused", + }); + + expect(result).toMatchObject({ + status: "degraded", + httpStatus: 200, + detail: + "The endpoint responded without a GraphQL introspection schema. Check that the URL points to a GraphQL endpoint and that introspection is enabled.", + }); + }), + ); + + it.effect("persists the introspection failure when tool sync is incomplete", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe({ message: "Bad credentials" }, { status: 401 }), + ), + ); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.url("/graphql"), + slug: "incomplete_sync", + }); + yield* createOrgConnection(executor, { + integration: "incomplete_sync", + name: "main", + template: "none", + value: "unused", + }); + + const connection = yield* executor.connections.get({ + owner: "org", + integration: IntegrationSlug.make("incomplete_sync"), + name: ConnectionName.make("main"), + }); + expect(connection?.lastHealth).toMatchObject({ + status: "degraded", + detail: + "Tool sync failing: The endpoint rejected the credential with HTTP 401. " + + "Check the credential and selected authentication method. Upstream said: Bad credentials", + }); + expect( + (yield* executor.tools.list()).filter( + (tool) => String(tool.integration) === "incomplete_sync", + ), + ).toEqual([]); + }), + ); }); describe("graphqlPlugin", () => { @@ -715,6 +937,9 @@ describe("graphqlPlugin", () => { }); expect(result.toolCount).toBe(2); expect(result.slug).toBe("test_api"); + expect( + yield* executor.integrations.healthCheck.candidates(IntegrationSlug.make("test_api")), + ).toEqual([]); yield* createOrgConnection(executor, { integration: "test_api", diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 1363b3a07b..8b986c119e 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -15,6 +15,8 @@ import { ToolName, ToolResult, type AuthMethodDescriptor, + type HealthCheckCandidate, + type HealthCheckResult, type IntegrationConfig, type IntegrationRecord, type PluginCtx, @@ -83,6 +85,124 @@ const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefi .find((message) => message !== undefined && message.length > 0); const GRAPHQL_PLUGIN_ID = "graphql"; +const GRAPHQL_HEALTH_OPERATION = "__schema"; +const GRAPHQL_HEALTH_CHECK = { operation: GRAPHQL_HEALTH_OPERATION }; +const GRAPHQL_HEALTH_CANDIDATE: HealthCheckCandidate = { + operation: GRAPHQL_HEALTH_OPERATION, + method: "post", + requiredArgCount: 0, + destructive: false, + summary: "Introspect the GraphQL schema", +}; +const GRAPHQL_INVALID_SCHEMA_DETAIL = + "The endpoint responded without a GraphQL introspection schema. Check that the URL points to a GraphQL endpoint and that introspection is enabled."; +const GRAPHQL_AUTH_DETAIL = "Check the credential and selected authentication method."; +const GRAPHQL_NETWORK_DETAIL = + "The GraphQL endpoint could not be reached. Check the URL and upstream availability, then try again."; + +const truncateHealthDetail = (text: string, max = 240): string => { + const normalized = text.replaceAll(/\s+/g, " ").trim(); + if (normalized.length <= max) return normalized; + return `${normalized.slice(0, max - 3)}...`; +}; + +const appendUpstreamMessage = (detail: string, message?: string): string => + message && message.trim().length > 0 + ? `${detail} Upstream said: ${truncateHealthDetail(message)}` + : detail; + +const isAuthMessage = (message: string | undefined): boolean => + message !== undefined && + /authoriz|authenticat|forbidden|permission|credential|api.?key|access denied|access token|invalid token|token expired|logged in|sign in/i.test( + message, + ); + +/** Upstream error text can echo the request back (URLs with query params, + * auth headers), so scrub every credential value out of anything that leaves + * as `detail` so a probe can never leak the secret it authenticated with. */ +const scrubCredentialValues = (text: string, values: Record): string => + Object.values(values) + .filter((value): value is string => typeof value === "string" && value.length > 0) + .reduce((out, secret) => out.split(secret).join("[redacted]"), text); + +const missingCredentialVariables = ( + method: GraphqlAuthMethod | undefined, + values: Record, +): readonly string[] => { + if (!method || method.kind === "none") return []; + const required = + method.kind === "oauth2" ? [TOKEN_VARIABLE] : requiredPlacementVariables(method.placements); + return required.filter((variable) => { + const value = values[variable]; + return value == null || value.length === 0; + }); +}; + +const healthFromIntrospectionError = ( + error: GraphqlIntrospectionError, + checkedAt: number, +): HealthCheckResult => { + const upstream = error.upstreamMessage; + const httpStatus = error.status; + + if (httpStatus === 401 || httpStatus === 403 || isAuthMessage(upstream)) { + const statusDetail = + httpStatus === 401 || httpStatus === 403 + ? `The endpoint rejected the credential with HTTP ${httpStatus}.` + : "The endpoint rejected the credential."; + return { + status: "expired", + ...(httpStatus !== undefined ? { httpStatus } : {}), + checkedAt, + detail: appendUpstreamMessage(`${statusDetail} ${GRAPHQL_AUTH_DETAIL}`, upstream), + }; + } + + if ( + error.reason === "invalid-json" || + error.reason === "invalid-shape" || + error.reason === "missing-schema" + ) { + return { + status: "degraded", + ...(httpStatus !== undefined ? { httpStatus } : {}), + checkedAt, + detail: GRAPHQL_INVALID_SCHEMA_DETAIL, + }; + } + + if (error.reason === "network") { + return { + status: "degraded", + checkedAt, + detail: GRAPHQL_NETWORK_DETAIL, + }; + } + + if (error.reason === "graphql-errors") { + return { + status: "degraded", + ...(httpStatus !== undefined ? { httpStatus } : {}), + checkedAt, + detail: appendUpstreamMessage( + "Schema introspection returned GraphQL errors. Check that introspection is enabled and the credential can read the schema.", + upstream, + ), + }; + } + + return { + status: "degraded", + ...(httpStatus !== undefined ? { httpStatus } : {}), + checkedAt, + detail: appendUpstreamMessage( + httpStatus !== undefined + ? `Schema introspection failed with HTTP ${httpStatus}. Check the endpoint and upstream status, then try again.` + : "Schema introspection failed. Check the endpoint and credential, then try again.", + upstream, + ), + }; +}; // --------------------------------------------------------------------------- // Extension input shapes @@ -526,6 +646,97 @@ const introspectForConnection = ( ).pipe(Effect.provide(httpClientLayer)); }; +const checkGraphqlHealth = (input: { + readonly integration: IntegrationRecord; + readonly credential: { + readonly template: AuthTemplateSlug; + readonly values: Record; + }; + readonly spec?: { readonly operation: string }; + readonly httpClientLayer: Layer.Layer; +}): Effect.Effect => + Effect.gen(function* () { + const checkedAt = Date.now(); + const decoded = yield* decodeGraphqlIntegrationConfig(input.integration.config).pipe( + Effect.option, + ); + if (Option.isNone(decoded)) { + return { + status: "unknown", + checkedAt, + detail: "The GraphQL integration configuration is invalid. Edit it, then try again.", + } satisfies HealthCheckResult; + } + + const config = decoded.value; + const operation = + input.spec?.operation ?? + (config.introspectionHash === undefined ? GRAPHQL_HEALTH_OPERATION : undefined); + if (operation === undefined) { + return { + status: "unknown", + checkedAt, + detail: "No live schema health check is configured.", + } satisfies HealthCheckResult; + } + if (operation !== GRAPHQL_HEALTH_OPERATION) { + return { + status: "unknown", + checkedAt, + detail: `GraphQL health check operation "${operation}" is not supported. Use ${GRAPHQL_HEALTH_OPERATION}.`, + } satisfies HealthCheckResult; + } + + const method = config.authenticationTemplate.find( + (candidate: GraphqlAuthMethod) => candidate.slug === String(input.credential.template), + ); + const missing = missingCredentialVariables(method, input.credential.values); + if (missing.length > 0) { + return { + status: "expired", + checkedAt, + detail: `Enter a credential value for ${missing.join(", ")}, then try again.`, + } satisfies HealthCheckResult; + } + + const auth = introspectHeadersForConnection( + config, + input.credential.values, + input.credential.template, + ); + const result = yield* introspect( + config.endpoint, + Object.keys(auth.headers).length > 0 ? auth.headers : undefined, + Object.keys(auth.queryParams).length > 0 ? auth.queryParams : undefined, + ).pipe( + Effect.provide(input.httpClientLayer), + Effect.map((introspection) => ({ ok: true as const, introspection })), + Effect.catchTag("GraphqlIntrospectionError", (error) => + Effect.succeed({ ok: false as const, error }), + ), + ); + + if (!result.ok) { + const verdict = healthFromIntrospectionError(result.error, checkedAt); + return verdict.detail === undefined + ? verdict + : { ...verdict, detail: scrubCredentialValues(verdict.detail, input.credential.values) }; + } + + const queryType = result.introspection.__schema.queryType?.name; + return { + status: "healthy", + httpStatus: 200, + checkedAt, + ...(queryType != null + ? { + identity: `GraphQL schema: ${queryType}`, + responseSample: [{ path: "__schema.queryType.name", value: queryType }], + } + : {}), + } satisfies HealthCheckResult; + }); + /** Introspect an integration's endpoint (with this connection's credential), * prepare its operations, persist the bindings, and return them. Invoked from * `invokeTool` on a cache miss — i.e. when an integration was registered @@ -678,13 +889,16 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { // using that connection's credential. if (input.introspectionJson === undefined) { yield* ctx.transaction( - ctx.core.integrations.register({ - slug, - name: baseConfig.name, - description: input.description?.trim() || baseConfig.name, - config: baseConfig, - canRemove: true, - canRefresh: true, + Effect.gen(function* () { + yield* ctx.core.integrations.register({ + slug, + name: baseConfig.name, + description: input.description?.trim() || baseConfig.name, + config: baseConfig, + canRemove: true, + canRefresh: true, + }); + yield* ctx.core.integrations.setHealthCheck(slug, GRAPHQL_HEALTH_CHECK); }), ); return { slug: String(slug), name: baseConfig.name, toolCount: 0 }; @@ -773,10 +987,18 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { : current.authenticationTemplate, }); - yield* ctx.core.integrations.update(IntegrationSlug.make(slug), { - description: next.name, - config: next, - }); + const integration = IntegrationSlug.make(slug); + yield* ctx.transaction( + Effect.gen(function* () { + yield* ctx.core.integrations.update(integration, { + description: next.name, + config: next, + }); + if (next.introspectionHash === undefined) { + yield* ctx.core.integrations.setHealthCheck(integration, GRAPHQL_HEALTH_CHECK); + } + }), + ); }); /** Read the integration's decoded config (or `null` when absent / malformed). @@ -899,6 +1121,21 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { describeAuthMethods: describeGraphqlAuthMethods, describeIntegrationDisplay: describeGraphqlIntegrationDisplay, + listHealthCheckCandidates: ({ integration }) => { + const config = Option.getOrUndefined( + decodeGraphqlIntegrationConfigOption(integration.config), + ); + return Effect.succeed( + config && config.introspectionHash === undefined ? [GRAPHQL_HEALTH_CANDIDATE] : [], + ); + }, + checkHealth: (input) => + checkGraphqlHealth({ + integration: input.integration, + credential: input.credential, + spec: input.spec, + httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer, + }), staticIntegrations: (self: GraphqlPluginExtension) => [ { @@ -995,21 +1232,33 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { Effect.catch(() => Effect.succeed({} as Record)), ) : ({} as Record); - const introspection = yield* introspectForConnection( + const introspectionResult = yield* introspectForConnection( graphqlConfig, introspectionJson, values, template, options?.httpClientLayer ?? httpClientLayer, - ).pipe(Effect.option); - if (Option.isNone(introspection)) { - return incomplete("GraphQL introspection could not be loaded."); + ).pipe( + Effect.map((introspection) => ({ ok: true as const, introspection })), + Effect.catchTag("GraphqlIntrospectionError", (error) => + Effect.succeed({ ok: false as const, error }), + ), + ); + if (!introspectionResult.ok) { + // One classifier for introspection failures: the persisted sync + // verdict carries the same actionable text as a live health probe, + // scrubbed of the credential it authenticated with. + const verdict = healthFromIntrospectionError(introspectionResult.error, Date.now()); + return incomplete( + scrubCredentialValues(verdict.detail ?? introspectionResult.error.message, values), + ); } - const extracted = yield* extract(introspection.value).pipe(Effect.option); + const introspection = introspectionResult.introspection; + const extracted = yield* extract(introspection).pipe(Effect.option); if (Option.isNone(extracted)) { return incomplete("GraphQL introspection result could not be converted to tools."); } - const prepared = prepareOperations(extracted.value.result.fields, introspection.value); + const prepared = prepareOperations(extracted.value.result.fields, introspection); return { tools: buildToolDefs(prepared), definitions: extracted.value.definitions, diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index cc21e4fd36..dddbfbe805 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -115,6 +115,8 @@ function AccountRow(props: { const displayLabel = identity ?? String(connection.name); const expired = status === "expired"; + const needsHealthAttention = status === "expired" || status === "degraded"; + const healthDetail = needsHealthAttention ? probe?.detail : undefined; const missingOAuthScopes = connection.missingOAuthScopes ?? []; const handleCheck = async () => { @@ -150,9 +152,9 @@ function AccountRow(props: { className={`size-2 shrink-0 rounded-full ${indicator.dot}`} /> {displayLabel} - {expired ? ( - - Expired + {needsHealthAttention ? ( + + {HEALTH_STATUS_LABEL[status]} ) : null} {needsReconsent ? ( @@ -166,6 +168,11 @@ function AccountRow(props: { {connection.description} ) : null} + {healthDetail ? ( + + {healthDetail} + + ) : null} {needsReconsent ? ( This connection wasn't granted all the access this integration now needs. diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index d91f981844..6003fed3cc 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -1817,13 +1817,9 @@ function AddAccountModalView(props: AddAccountModalProps) { if (continueError !== null) setContinueError(null); }; - // Check the key works: probe the pasted credential WITHOUT saving the - // connection. When the integration has a configured health check we run it; - // otherwise we run the inline-picked candidate and, if it comes back healthy, - // save it as the integration's health check (so it's configured "then"). - // Probe the pasted credential WITHOUT saving the connection. With a - // configured check the panel's Check runs this directly; with none it runs - // via handleCandidateProbe, which drafts a spec from the picked candidate. + // Probe the pasted credential without saving the connection. A configured + // health check runs directly; otherwise the picked candidate supplies a + // draft spec and becomes the integration's health check when it succeeds. const handleValidate = async (draftSpec?: HealthCheckSpec) => { const payloadOrigin = createCredentialPayloadOrigin({ origin: credentialOrigin, @@ -1832,7 +1828,7 @@ function AddAccountModalView(props: AddAccountModalProps) { onePasswordItemId, singleInput, }); - if (!method || payloadOrigin === null || validating) return; + if (!method || payloadOrigin === null || validating) return null; setValidating(true); const exit = await doValidate({ payload: { @@ -1849,7 +1845,7 @@ function AddAccountModalView(props: AddAccountModalProps) { if (Exit.isFailure(exit)) { setValidationResult(null); toast.error(messageFromExit(exit, "Couldn't check the key")); - return; + return null; } const result = exit.value; setValidationResult(result); @@ -1883,19 +1879,50 @@ function AddAccountModalView(props: AddAccountModalProps) { return probedIdentity; }); } + return result; }; - // No configured check: build a draft spec from the picked candidate and - // probe with it. - const handleCandidateProbe = async () => { - if (!hcCandidateReady) return; + const candidateHealthSpec = (): HealthCheckSpec | null => { + if (!hcCandidateReady) return null; const argEntries = Object.entries(hcArgs) .map(([key, value]) => [key, value.trim()] as const) .filter(([, value]) => value.length > 0); - await handleValidate({ + return { operation: hcOperation, ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), - }); + }; + }; + + // No configured check: build a draft spec from the picked candidate and + // probe with it. + const handleCandidateProbe = async () => { + const spec = candidateHealthSpec(); + if (spec) await handleValidate(spec); + }; + + const handleContinue = async () => { + if (credentialPayloadOrigin === null) { + setContinueError(singleInput ? "Enter the key first" : "Fill in every credential field"); + return; + } + + if (canCheckKey) { + const result = + validationResult?.status === "healthy" + ? validationResult + : hasHealthCheck + ? await handleValidate() + : await handleValidate(candidateHealthSpec() ?? undefined); + if (result?.status !== "healthy") { + setContinueError( + result?.detail ?? "Check the credential and resolve the connection error to continue.", + ); + return; + } + } + + setContinueError(null); + setWizardStep("place"); }; // The user clicked a field in the probe's response: that field IS the @@ -2875,20 +2902,7 @@ function AddAccountModalView(props: AddAccountModalProps) { : "Connect with OAuth"} ) : wizardActive && wizardStep === "validate" ? ( - ) : ( diff --git a/packages/react/src/components/tool-detail.tsx b/packages/react/src/components/tool-detail.tsx index cbd5fd6569..822856f8e3 100644 --- a/packages/react/src/components/tool-detail.tsx +++ b/packages/react/src/components/tool-detail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { toolSchemaAtom } from "../api/atoms"; @@ -506,18 +506,23 @@ function PolicyBadgeMenu(props: { // Empty state // --------------------------------------------------------------------------- -export function ToolDetailEmpty(props: { hasTools: boolean }) { +export function ToolDetailEmpty(props: { + hasTools: boolean; + title?: string; + description?: string; + action?: ReactNode; +}) { + const title = props.title ?? (props.hasTools ? "Select a tool" : "No tools available"); return (
-
-

- {props.hasTools ? "Select a tool" : "No tools available"} -

- {props.hasTools && ( +
+

{title}

+ {props.description || props.hasTools ? (

- Choose from the list to see what it does. + {props.description ?? "Choose from the list to see what it does."}

- )} + ) : null} + {props.action ?
{props.action}
: null}
); diff --git a/packages/react/src/components/tool-tree.tsx b/packages/react/src/components/tool-tree.tsx index 2005687ab9..9467ddd8ec 100644 --- a/packages/react/src/components/tool-tree.tsx +++ b/packages/react/src/components/tool-tree.tsx @@ -309,6 +309,8 @@ export function ToolTree(props: { * beneath each section. The same tool name can appear under two accounts (NOT * deduped). When false/unset, render one flat tree (unchanged). */ groupByConnection?: boolean; + /** Label shown when this tree has no tools and no active filter. */ + emptyLabel?: string; }) { const { tools, @@ -410,7 +412,9 @@ export function ToolTree(props: {
{filteredTools.length === 0 ? (
- {terms.length > 0 ? "No tools match your filter" : "No tools available"} + {terms.length > 0 + ? "No tools match your filter" + : (props.emptyLabel ?? "No tools available")}
) : groupByConnection ? ( accountGroups.map((group) => ( diff --git a/packages/react/src/pages/integration-detail.tsx b/packages/react/src/pages/integration-detail.tsx index a84b30cba8..0f2c0d1bd2 100644 --- a/packages/react/src/pages/integration-detail.tsx +++ b/packages/react/src/pages/integration-detail.tsx @@ -2,7 +2,9 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react"; import * as Exit from "effect/Exit"; +import { toast } from "sonner"; import { trackEvent } from "../api/analytics"; +import { messageFromExit } from "../api/error-reporting"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AuthTemplateSlug, @@ -13,6 +15,7 @@ import { type Owner, } from "@executor-js/sdk/shared"; import { + checkConnectionHealth, connectionsAllAtom, integrationToolsAllAtom, integrationsOptimisticAtom, @@ -21,7 +24,11 @@ import { refreshConnection, removeIntegrationOptimistic, } from "../api/atoms"; -import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys"; +import { + connectionCheckKeys, + connectionWriteKeys, + integrationWriteKeys, +} from "../api/reactivity-keys"; import { ToolTree } from "../components/tool-tree"; import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; import type { ToolSummary } from "../components/tool-tree"; @@ -36,6 +43,7 @@ import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; +import { useConnectionsHealth } from "../lib/use-connection-health"; import { integrationDetailInternalTabFromSearch, type IntegrationDetailInternalTab, @@ -78,6 +86,7 @@ export function IntegrationDetailPage(props: { const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug)); const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" }); const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" }); + const doCheckHealth = useAtomSet(checkConnectionHealth, { mode: "promiseExit" }); // Policies are owner-partitioned on write; the integration policy menu writes // Workspace (org) rules, preserving the prior default behavior. const policyActions = usePolicyActions("org"); @@ -100,6 +109,7 @@ export function IntegrationDetailPage(props: { const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [retryingTools, setRetryingTools] = useState(false); const [editSheetOpen, setEditSheetOpen] = useState(false); const [activeTab, setActiveTab] = useState(() => integrationDetailInternalTabFromSearch(props.tab), @@ -242,6 +252,25 @@ export function IntegrationDetailPage(props: { ); }, [connectionsResult, slug]); + const healthProbeFor = useConnectionsHealth(integrationConnections); + const toolsHealthIssue = useMemo(() => { + const issues = integrationConnections + .map((connection) => ({ connection, probe: healthProbeFor(connection) })) + .filter( + ( + entry, + ): entry is { + connection: Connection; + probe: NonNullable>; + } => entry.probe?.status === "expired" || entry.probe?.status === "degraded", + ) + .sort((a, b) => { + const rank = (status: string): number => (status === "expired" ? 0 : 1); + return rank(a.probe.status) - rank(b.probe.status); + }); + return issues[0] ?? null; + }, [healthProbeFor, integrationConnections]); + // Account-grouped tool rows for the Tools tab. NOT deduped across // connections: one row per (owner, connection, tool). The leaf's `name` is the // policy id `.` so leaf policy patterns stay correct, while @@ -281,6 +310,17 @@ export function IntegrationDetailPage(props: { const selection = selectedToolId ? (selectionById.get(selectedToolId) ?? null) : null; const selectedAddress = selection?.address ?? null; const selectedBareName = selection?.bareName ?? null; + const connectedEmptyTools = + !isBuiltInIntegration && integrationConnections.length > 0 && integrationTools.length === 0; + const hasToolSyncIssue = connectedEmptyTools && toolsHealthIssue !== null; + const emptyToolsTitle = toolsHealthIssue + ? toolsHealthIssue.probe.status === "expired" + ? "Connection rejected" + : "Tool sync failed" + : "Checking connection"; + const emptyToolsDescription = + toolsHealthIssue?.probe.detail ?? + "Checking whether this connection can load the GraphQL schema."; // Declared auth methods — derived server-side from the owning plugin's config // and carried on the integration catalog response. This is authoritative even @@ -362,6 +402,56 @@ export function IntegrationDetailPage(props: { setRefreshing(false); }; + const handleRetryTools = async () => { + if (retryingTools) return; + setRetryingTools(true); + let refreshedAny = false; + // The first still-unhealthy verdict (or failed call) explains why nothing + // synced; without it the button spins and stops with no visible change. + let firstProblem: string | null = null; + for (const connection of integrationConnections) { + const ref = { + owner: connection.owner, + integration: slug, + name: connection.name, + }; + const health = await doCheckHealth({ + params: ref, + query: {}, + reactivityKeys: connectionCheckKeys, + }); + if (Exit.isFailure(health)) { + firstProblem ??= messageFromExit(health, "Health check failed"); + continue; + } + if (health.value.status !== "healthy") { + firstProblem ??= health.value.detail ?? "The connection is still unhealthy."; + continue; + } + const refreshed = await doRefresh({ + params: ref, + reactivityKeys: connectionWriteKeys, + }); + if (Exit.isFailure(refreshed)) { + firstProblem ??= messageFromExit(refreshed, "Tool sync failed"); + continue; + } + refreshedAny = true; + } + trackEvent("integration_refreshed", { + integration_slug: String(slug), + connection_count: integrationConnections.length, + success: refreshedAny, + }); + if (refreshedAny) { + refreshTools(); + toast.success("Tools synced"); + } else { + toast.error(firstProblem ?? "No connections to sync"); + } + setRetryingTools(false); + }; + const handleOpenAddConnection = () => { setActiveTab("accounts"); setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` }); @@ -510,6 +600,7 @@ export function IntegrationDetailPage(props: { onClearPolicy={(pattern) => void policyActions.clear(pattern)} policies={sortedPolicies} groupByConnection={!isBuiltInIntegration} + emptyLabel={hasToolSyncIssue ? emptyToolsTitle : undefined} />
@@ -539,6 +630,23 @@ export function IntegrationDetailPage(props: { onAddConnection={handleOpenAddConnection} canAddConnection={accountsMethods.length > 0} /> + ) : hasToolSyncIssue ? ( + void handleRetryTools()} + disabled={retryingTools} + > + {retryingTools ? "Checking…" : "Check and sync tools"} + + } + /> ) : ( 0} /> )} From c333ea2dbb1904afea51759162ba7d8c3daa3e81 Mon Sep 17 00:00:00 2001 From: David Rossiter <123392630+davidwrossiter@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:16:12 +0100 Subject: [PATCH 022/328] preserve cloudflare access config across deploys (#1495) --- apps/host-cloudflare/README.md | 22 +++--- apps/host-cloudflare/package.json | 1 + apps/host-cloudflare/scripts/deploy.sh | 18 +++-- .../src/auth/cloudflare-access.ts | 5 +- apps/host-cloudflare/src/config.test.ts | 74 +++++++++++++++++++ apps/host-cloudflare/src/config.ts | 49 ++++++++++-- .../src/worker.e2e.node.test.ts | 58 ++++++++++++--- apps/host-cloudflare/src/worker.ts | 20 ++++- apps/host-cloudflare/wrangler.jsonc | 16 ++-- bun.lock | 1 + 10 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 apps/host-cloudflare/src/config.test.ts diff --git a/apps/host-cloudflare/README.md b/apps/host-cloudflare/README.md index 1770dbbe85..9700570289 100644 --- a/apps/host-cloudflare/README.md +++ b/apps/host-cloudflare/README.md @@ -36,14 +36,14 @@ bunx wrangler login bun run deploy:setup # apps/host-cloudflare — provisions D1 + secret + deploys ``` -`deploy:setup` (scripts/deploy.sh) is idempotent: it creates/reuses the -`executor` D1 database, writes its id into `wrangler.jsonc`, generates + -uploads `EXECUTOR_SECRET_KEY`, and deploys. It then prints the one manual step. +`deploy:setup` (scripts/deploy.sh) is idempotent. It creates or reuses the +`executor` D1 database, writes its id into `wrangler.jsonc`, generates and +uploads `EXECUTOR_SECRET_KEY`, then deploys. It then prints the one manual step. ### The one manual step — Cloudflare Access -The Worker returns 401 until it's behind a Cloudflare Access application. In the -Zero Trust dashboard: +After the first deploy, API and MCP requests return 503 and name the missing +Access variables until configuration is complete. In the Zero Trust dashboard: 1. **Access → Applications → Add an application → Self-hosted** 2. Application domain: `executor-cloudflare..workers.dev` @@ -52,13 +52,17 @@ Zero Trust dashboard: ```bash bunx wrangler deploy \ --var ACCESS_AUD: \ - --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com + --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com \ + --var ADMIN_EMAILS: ``` - (or set them in `wrangler.jsonc` and redeploy) Now visiting the Worker prompts an Access login; the Worker validates the issued -JWT on every request. MCP clients present an Access JWT or -`Cf-Access-Client-Id`/`-Secret` service-token headers. +JWT on every request. Unauthenticated requests return 401. MCP clients present +an Access JWT or `Cf-Access-Client-Id`/`-Secret` service-token headers. + +The Access values are live Worker variables, not values in `wrangler.jsonc`. +Wrangler's `keep_vars` option preserves them during later code deploys. Run the +command above again whenever you need to change them. ## Local development diff --git a/apps/host-cloudflare/package.json b/apps/host-cloudflare/package.json index d7159620d7..cab97d3677 100644 --- a/apps/host-cloudflare/package.json +++ b/apps/host-cloudflare/package.json @@ -53,6 +53,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", + "jsonc-parser": "^3.3.1", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", diff --git a/apps/host-cloudflare/scripts/deploy.sh b/apps/host-cloudflare/scripts/deploy.sh index 4d8a2857e8..130bd5507d 100755 --- a/apps/host-cloudflare/scripts/deploy.sh +++ b/apps/host-cloudflare/scripts/deploy.sh @@ -7,7 +7,7 @@ # wrangler.jsonc # 3. generates + uploads EXECUTOR_SECRET_KEY (the at-rest secret key) if unset # 4. deploys the Worker -# 5. prints the single manual step: the Cloudflare Access application +# 5. prints the single manual step: configure the Cloudflare Access application # # Idempotent — safe to re-run. Run from anywhere: # bash apps/host-cloudflare/scripts/deploy.sh @@ -71,18 +71,22 @@ cat <<'NEXT' ==> One manual step left: turn on Cloudflare Access (the auth layer) - The Worker is deployed but every request returns 401 until you put it behind - a Cloudflare Access application. In the Zero Trust dashboard: + The Worker is deployed but is not ready to serve requests until you configure + a Cloudflare Access application. API and MCP requests return 503 and name the + missing variables until configuration is complete. In the Zero Trust + dashboard: 1. Access -> Applications -> Add an application -> Self-hosted 2. Application domain: executor-cloudflare..workers.dev 3. Add an Access policy (e.g. "Emails ending in @yourcompany.com") 4. After saving, copy the Application Audience (AUD) tag, then set: - bunx wrangler deploy --var ACCESS_AUD: \ - --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com - (or edit the vars in wrangler.jsonc and redeploy) + bunx wrangler deploy --var ACCESS_AUD: \ + --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com \ + --var ADMIN_EMAILS: - That's it — visiting the Worker URL now prompts a Cloudflare Access login, + Wrangler preserves these live variables during later code deploys. + + That's it. Visiting the Worker URL now prompts a Cloudflare Access login, and the Worker validates the issued JWT on every request. NEXT diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts index 178f8818f5..3a206bb3b3 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts @@ -60,7 +60,9 @@ export const principalFromAccessClaims = ( export const makeAccessVerifier = (config: CloudflareConfig) => { const issuer = `https://${config.accessTeamDomain}`; // Cached, lazily-fetched team signing keys; jose handles rotation + caching. - const jwks = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`)); + const jwks = config.enableDevAuth + ? null + : createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`)); // Dev/single-user escape hatch: bypass Access entirely, every request is a // fixed admin. Only when explicitly enabled (and the instance is otherwise @@ -79,6 +81,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { const verify = (request: Request): Effect.Effect => Effect.gen(function* () { if (config.enableDevAuth) return devPrincipal; + if (!jwks) return null; const token = request.headers.get("Cf-Access-Jwt-Assertion"); if (!token) return null; diff --git a/apps/host-cloudflare/src/config.test.ts b/apps/host-cloudflare/src/config.test.ts new file mode 100644 index 0000000000..39aab1e7d3 --- /dev/null +++ b/apps/host-cloudflare/src/config.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "@effect/vitest"; +import { parse } from "jsonc-parser"; + +import { loadConfig } from "./config"; + +type ConfigEnv = Parameters[0]; + +const makeEnv = (overrides: Partial = {}): ConfigEnv => ({ + EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", + VITE_PUBLIC_SITE_URL: "https://executor.example.com", + ...overrides, +}); + +describe("loadConfig", () => { + it("rejects missing Cloudflare Access configuration outside local development", () => { + expect(() => loadConfig(makeEnv())).toThrowError( + "Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN and ACCESS_AUD before serving requests.", + ); + }); + + it("rejects the repository's former team-domain placeholder", () => { + expect(() => + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "your-team.cloudflareaccess.com", + ACCESS_AUD: "aud-tag", + }), + ), + ).toThrowError( + "Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN before serving requests.", + ); + }); + + it("allows local development to bypass Cloudflare Access", () => { + expect(loadConfig(makeEnv({ ENABLE_DEV_AUTH: "true" }))).toMatchObject({ + accessTeamDomain: "", + accessAud: "", + enableDevAuth: true, + }); + }); + + it("normalises configured Access values without requiring an administrator", () => { + expect( + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "https://Team.cloudflareaccess.com/", + ACCESS_AUD: " aud-tag ", + }), + ), + ).toMatchObject({ + accessTeamDomain: "Team.cloudflareaccess.com", + accessAud: "aud-tag", + adminEmails: [], + enableDevAuth: false, + }); + }); +}); + +describe("Cloudflare deployment configuration", () => { + it("preserves operator-managed Access variables across deploys", () => { + const config = parse(readFileSync(new URL("../wrangler.jsonc", import.meta.url), "utf8")) as { + readonly keep_vars?: boolean; + readonly vars?: Readonly>; + }; + + expect(config.keep_vars).toBe(true); + expect(config.vars).not.toHaveProperty("ACCESS_TEAM_DOMAIN"); + expect(config.vars).not.toHaveProperty("ACCESS_AUD"); + expect(config.vars).not.toHaveProperty("ADMIN_EMAILS"); + expect(config.vars).toHaveProperty("ENABLE_DEV_AUTH", "false"); + }); +}); diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index bded06965a..5085ce6e38 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -27,9 +27,9 @@ export interface CloudflareEnv { readonly MCP_SESSION: DurableObjectNamespace; readonly MCP_EXECUTION_OWNER?: DurableObjectNamespace; /** Zero Trust team domain, e.g. `your-team.cloudflareaccess.com`. */ - readonly ACCESS_TEAM_DOMAIN: string; + readonly ACCESS_TEAM_DOMAIN?: string; /** The Access application's AUD tag (the JWT audience to verify). */ - readonly ACCESS_AUD: string; + readonly ACCESS_AUD?: string; /** Claim holding the display name (default `name`). */ readonly ACCESS_NAME_CLAIM?: string; /** Claim holding the user's groups (default `groups`). */ @@ -72,12 +72,44 @@ export interface CloudflareConfig { readonly enableDevAuth: boolean; } +type CloudflareConfigEnv = Omit< + CloudflareEnv, + "DB" | "BLOBS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER" +>; + +type CloudflareAccessEnv = Pick< + CloudflareConfigEnv, + "ACCESS_TEAM_DOMAIN" | "ACCESS_AUD" | "ENABLE_DEV_AUTH" +>; + const splitLower = (value: string | undefined): readonly string[] => (value ?? "") .split(",") .map((part) => part.trim().toLowerCase()) .filter((part) => part.length > 0); +const normalizeAccessTeamDomain = (value: string | undefined): string => + (value ?? "") + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/+$/, ""); + +export const missingCloudflareAccessVars = (env: CloudflareAccessEnv): readonly string[] => { + if (env.ENABLE_DEV_AUTH === "true") return []; + const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN); + const accessAud = (env.ACCESS_AUD ?? "").trim(); + return [ + ...(accessTeamDomain.length === 0 || + accessTeamDomain.toLowerCase() === "your-team.cloudflareaccess.com" + ? ["ACCESS_TEAM_DOMAIN"] + : []), + ...(accessAud.length === 0 ? ["ACCESS_AUD"] : []), + ]; +}; + +export const cloudflareAccessConfigErrorMessage = (missingVars: readonly string[]): string => + `Cloudflare Access is not configured. Set ${missingVars.join(" and ")} before serving requests.`; + // The org slug doubles as a URL segment (`//policies`), so an // operator-set value must fit the shared grammar and avoid reserved root // segments — a colliding slug would shadow real routes (notably /api, /mcp, @@ -93,7 +125,7 @@ const resolveOrgSlug = (value: string | undefined): string => { return value; }; -export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { +export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { const secretKey = env.EXECUTOR_SECRET_KEY?.trim(); if (!secretKey || secretKey.length < 16) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the Worker must not boot without the at-rest secret key @@ -102,6 +134,13 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { ); } const enableDevAuth = env.ENABLE_DEV_AUTH === "true"; + const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN); + const accessAud = (env.ACCESS_AUD ?? "").trim(); + const missingAccessVars = missingCloudflareAccessVars(env); + if (missingAccessVars.length > 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: production must fail closed without a valid Access verifier + throw new Error(cloudflareAccessConfigErrorMessage(missingAccessVars)); + } const webBaseUrl = resolvePublicOrigin({ explicit: env.VITE_PUBLIC_SITE_URL, env: {} }); if (!webBaseUrl && !enableDevAuth && !warnedNoCloudflareOrigin) { warnedNoCloudflareOrigin = true; @@ -113,8 +152,8 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { ); } return { - accessTeamDomain: env.ACCESS_TEAM_DOMAIN.replace(/^https?:\/\//, "").replace(/\/+$/, ""), - accessAud: env.ACCESS_AUD, + accessTeamDomain, + accessAud, accessNameClaim: env.ACCESS_NAME_CLAIM ?? "name", accessGroupsClaim: env.ACCESS_GROUPS_CLAIM ?? "groups", adminEmails: splitLower(env.ADMIN_EMAILS), diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 84c6fb0111..99c2ecc3d1 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -19,6 +19,18 @@ import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsof const dir = fileURLToPath(new URL(".", import.meta.url)); const runId = randomUUID().slice(0, 8); +const ensureStaticAssets = () => { + // CI runs from a fresh checkout with no `vite build`, so `./dist` (the SPA + // assets dir wrangler.jsonc points `assets.directory` at) is absent and + // `unstable_dev`'s assets validation aborts boot. These tests drive the + // API/MCP surface (all `run_worker_first` paths), not the SPA. + const distIndex = resolve(dir, "../dist/index.html"); + if (!existsSync(distIndex)) { + mkdirSync(resolve(dir, "../dist"), { recursive: true }); + writeFileSync(distIndex, "executor"); + } +}; + // Inline spec (no network); registers one tool, exercising the D1 write path. const SPEC = JSON.stringify({ openapi: "3.0.0", @@ -77,21 +89,13 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { let worker: Unstable_DevWorker; beforeAll(async () => { - // CI runs from a fresh checkout with no `vite build`, so `./dist` (the SPA - // assets dir wrangler.jsonc points `assets.directory` at) is absent and - // `unstable_dev`'s assets validation aborts boot. This e2e drives the - // API/MCP surface (all `run_worker_first` paths), not the SPA, so a minimal - // placeholder index.html satisfies the validation without a real build. - const distIndex = resolve(dir, "../dist/index.html"); - if (!existsSync(distIndex)) { - mkdirSync(resolve(dir, "../dist"), { recursive: true }); - writeFileSync(distIndex, "executor"); - } + ensureStaticAssets(); worker = await unstable_dev(resolve(dir, "worker.ts"), { config: resolve(dir, "../wrangler.jsonc"), ip: "127.0.0.1", local: true, + persist: false, experimental: { disableExperimentalWarning: true }, vars: { EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", @@ -474,3 +478,37 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { expect(resumed.result?.structuredContent?.status).toBe("completed"); }, 60_000); }); + +describe("cloudflare host configuration errors", () => { + let worker: Unstable_DevWorker; + + beforeAll(async () => { + ensureStaticAssets(); + worker = await unstable_dev(resolve(dir, "worker.ts"), { + config: resolve(dir, "../wrangler.jsonc"), + ip: "127.0.0.1", + local: true, + persist: false, + experimental: { disableExperimentalWarning: true }, + vars: { + EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", + }, + }); + }, 120_000); + + afterAll(async () => { + await worker?.stop(); + }); + + it("returns an actionable response when Cloudflare Access is not configured", async () => { + for (const path of ["/api/account/me", "/mcp"]) { + const response = await worker.fetch(path); + + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + await expect(response.text()).resolves.toBe( + "Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN and ACCESS_AUD before serving requests.\n", + ); + } + }); +}); diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index 74a1be074b..ac9c1b30b7 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -1,5 +1,9 @@ import { makeCloudflareApp } from "./app"; -import type { CloudflareEnv } from "./config"; +import { + cloudflareAccessConfigErrorMessage, + missingCloudflareAccessVars, + type CloudflareEnv, +} from "./config"; // The MCP Durable Object classes, bound in wrangler.jsonc. They must be exported // at the Worker entry module scope for the runtime to find them. @@ -27,8 +31,22 @@ const resolveHandler = (env: CloudflareEnv) => { return handlerPromise; }; +const accessConfigErrorResponse = (missingVars: readonly string[]): Response => + new Response(`${cloudflareAccessConfigErrorMessage(missingVars)}\n`, { + status: 503, + headers: { + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + }, + }); + export default { fetch: async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { + const missingAccessVars = missingCloudflareAccessVars(env); + if (missingAccessVars.length > 0) { + return accessConfigErrorResponse(missingAccessVars); + } + const serve = await resolveHandler(env); if (new URL(request.url).pathname === "/mcp") { return serve.mcp(request, env, ctx); diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index 6b3a12b7ff..1580c8c3d8 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -4,6 +4,9 @@ "compatibility_date": "2025-04-01", "compatibility_flags": ["nodejs_compat"], "main": "src/worker.ts", + // Access configuration is set per installation after the first deploy. + // Preserve those live bindings when this source config does not declare them. + "keep_vars": true, "observability": { "enabled": true }, // The web UI (Workers Static Assets) — the shared multiplayer SPA built by // `vite build` into ./dist. `single-page-application` serves index.html for @@ -49,17 +52,16 @@ { "tag": "v1", "new_sqlite_classes": ["McpSessionDO"] }, { "tag": "v2", "new_sqlite_classes": ["McpExecutionOwnerDirectoryDO"] }, ], - // Cloudflare Access is the entire auth layer: the Worker validates the - // Cf-Access-Jwt-Assertion JWT against the team JWKS. Set these to your Zero - // Trust team domain + the Access application's AUD tag. EXECUTOR_SECRET_KEY - // (the at-rest secret-encryption key) is a SECRET — set it with + // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD, + // and ADMIN_EMAILS are installation-specific live vars, set after the first + // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest + // secret-encryption key) is a SECRET, set it with // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. "vars": { - "ACCESS_TEAM_DOMAIN": "your-team.cloudflareaccess.com", - "ACCESS_AUD": "", "ACCESS_NAME_CLAIM": "name", "ACCESS_GROUPS_CLAIM": "groups", - "ADMIN_EMAILS": "", + // Never preserve a production dev-auth override through keep_vars. + "ENABLE_DEV_AUTH": "false", "SELF_HOSTED_ORG_ID": "default", "SELF_HOSTED_ORG_NAME": "Default", // VITE_PUBLIC_SITE_URL is intentionally unset: with no static URL the worker diff --git a/bun.lock b/bun.lock index 657f7fc652..c46f8d9ba6 100644 --- a/bun.lock +++ b/bun.lock @@ -206,6 +206,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", + "jsonc-parser": "^3.3.1", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", From 8369cdde6da7585014d9d76086ae29313f3a86ef Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:16:26 -0700 Subject: [PATCH 023/328] Backfill last_seen_at from connection activity too (#1493) --- apps/cloud/scripts/backfill-subjects.ts | 43 ++++++++++++++++++------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/apps/cloud/scripts/backfill-subjects.ts b/apps/cloud/scripts/backfill-subjects.ts index a2ffa7f959..ab3d0bc0e2 100644 --- a/apps/cloud/scripts/backfill-subjects.ts +++ b/apps/cloud/scripts/backfill-subjects.ts @@ -14,13 +14,17 @@ // // created_at ← the tenant+subject's oldest connection, the closest honest // answer to "when did this user first appear". -// last_seen_at ← NULL. The column means "seen on a request", no request has -// been recorded, and the console renders NULL as "Never" — -// which is true since the sighting code deployed. +// last_seen_at ← the tenant+subject's newest connection `updated_at`, the +// latest activity storage can attest to. Rendering every +// pre-0012 user as "Never" was literally true of the sighting +// column but reads as "inactive user" to an operator, which +// is the wrong answer for a tenant full of active people. The +// first real sighting overwrites this with request truth. // -// Idempotent — ON CONFLICT on the (tenant, external_id) unique index leaves -// rows the runtime has since minted (or a prior run inserted) untouched, so -// re-running is safe and never rewinds a live row's fields. +// Idempotent — the insert's ON CONFLICT on the (tenant, external_id) unique +// index leaves rows the runtime has since minted (or a prior run inserted) +// untouched, and the last_seen_at repair only fills NULLs — so re-running is +// safe and never rewinds a live row's fields. // Pass --dry-run to print the plan without writing. // --------------------------------------------------------------------------- @@ -37,8 +41,12 @@ if (!connectionString) { const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" }); -const rows = await sql<{ tenant: string; subject: string; first_connection_at: Date }[]>` - SELECT tenant, subject, MIN(created_at) AS first_connection_at +const rows = await sql< + { tenant: string; subject: string; first_connection_at: Date; last_activity_at: Date }[] +>` + SELECT tenant, subject, + MIN(created_at) AS first_connection_at, + MAX(updated_at) AS last_activity_at FROM connection WHERE subject <> '' GROUP BY tenant, subject @@ -47,20 +55,33 @@ const rows = await sql<{ tenant: string; subject: string; first_connection_at: D console.log(`${rows.length} (tenant, subject) pair(s) with connections`); let inserted = 0; +let repaired = 0; for (const row of rows) { console.log( - `${row.tenant} ${row.subject} first connection ${row.first_connection_at.toISOString()}`, + `${row.tenant} ${row.subject} first connection ${row.first_connection_at.toISOString()} last activity ${row.last_activity_at.toISOString()}`, ); if (dryRun) continue; + const lastSeenMs = row.last_activity_at.getTime(); const result = await sql` INSERT INTO subject (row_id, tenant, external_id, created_at, last_seen_at, status) - VALUES (${createId()}, ${row.tenant}, ${row.subject}, ${row.first_connection_at}, NULL, NULL) + VALUES (${createId()}, ${row.tenant}, ${row.subject}, ${row.first_connection_at}, ${lastSeenMs}, NULL) ON CONFLICT (tenant, external_id) DO NOTHING `; inserted += result.count; + if (result.count > 0) continue; + // The row predates this run but may still carry NULL from a run of the + // pre-repair version of this script. Fill it in; a sighted row keeps its + // request-truth timestamp. + const repair = await sql` + UPDATE subject SET last_seen_at = ${lastSeenMs} + WHERE tenant = ${row.tenant} AND external_id = ${row.subject} AND last_seen_at IS NULL + `; + repaired += repair.count; } console.log( - dryRun ? `dry run — would insert up to ${rows.length} row(s)` : `inserted ${inserted} row(s)`, + dryRun + ? `dry run — would insert up to ${rows.length} row(s)` + : `inserted ${inserted} row(s), repaired last_seen_at on ${repaired} row(s)`, ); await sql.end(); From 010ea98e520643731b07e68e21119f12b8ef1505 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:03:20 -0700 Subject: [PATCH 024/328] Keep the pasted credential across the wizard's mid-flow refetch (#1499) --- .changeset/tidy-keys-remain.md | 5 +++++ .../src/components/add-account-modal.tsx | 21 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 .changeset/tidy-keys-remain.md diff --git a/.changeset/tidy-keys-remain.md b/.changeset/tidy-keys-remain.md new file mode 100644 index 0000000000..65e233663f --- /dev/null +++ b/.changeset/tidy-keys-remain.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +**Fix: the add-connection wizard no longer wipes a pasted credential when the key check saves a health check mid-flow** diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 6003fed3cc..376b5c2c8f 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -1303,8 +1303,19 @@ function AddAccountModalView(props: AddAccountModalProps) { setMethodId(allMethods[0]!.id); }, [allMethods, methodId]); + // Apply the handoff prefill ONCE per handoff key (tracked by ref). The + // effect's deps include `allMethods`, which gets a new identity whenever the + // integration refetches — and the wizard itself triggers a refetch mid-flow + // (Continue probes the key and saves the health check). Re-running the reset + // then would wipe the credential the user just pasted. + const handoffAppliedKey = useRef(null); useEffect(() => { if (!initialState) return; + // Methods load in a second fetch after the modal opens; when the handoff + // preselects one, retry until they land so the match has something to hit. + if (initialState.template != null && allMethods.length === 0) return; + if (handoffAppliedKey.current === initialState.key) return; + handoffAppliedKey.current = initialState.key; const initialMethod = initialState.template ? allMethods.find( (m: AuthMethod) => @@ -1900,6 +1911,12 @@ function AddAccountModalView(props: AddAccountModalProps) { if (spec) await handleValidate(spec); }; + // Continue probes the key, but only a DEFINITIVE rejection (the upstream + // answered 401/403 → "expired") blocks the step. An inconclusive probe — + // upstream unreachable, no runnable check, the probe call itself failing — + // advances: the pasted key may be perfectly valid, and stranding the user on + // an upstream outage would make the wizard depend on availability, not + // credential correctness. const handleContinue = async () => { if (credentialPayloadOrigin === null) { setContinueError(singleInput ? "Enter the key first" : "Fill in every credential field"); @@ -1913,9 +1930,9 @@ function AddAccountModalView(props: AddAccountModalProps) { : hasHealthCheck ? await handleValidate() : await handleValidate(candidateHealthSpec() ?? undefined); - if (result?.status !== "healthy") { + if (result?.status === "expired") { setContinueError( - result?.detail ?? "Check the credential and resolve the connection error to continue.", + result.detail ?? "Check the credential and resolve the connection error to continue.", ); return; } From 1178e3b31cd62cd2c05d6504d1586c2d8f018692 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:04:53 -0700 Subject: [PATCH 025/328] Add the Artifacts console pages (#1472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add artifact table and core artifacts API * Add MCP-Apps shell package and first-party render-ui tools * Add artifact tool tests and port the shell browser suite * Ship the MCP-Apps shell in binaries and revive the sunpeak e2e harness * Add the Artifacts console pages and embed the MCP-Apps shell * Add artifacts e2e scenarios * Load the artifact shell in the browser only The MCP-Apps shell imports @tailwindcss/browser, which touches document at import scope. Importing the shell statically from the root routes put it in cloud's SSR graph, so every document request failed with "document is not defined" while the entry graph loaded. The renderer seam now carries an async loader instead of a component. The composition roots register the shell through a dynamic import that only resolves in the browser, and the artifact page renders a placeholder frame server-side, hydrating the shell behind ClientOnly and Suspense. * Host the artifact shell in an iframe over the MCP-Apps protocol The artifact page mounted McpAppsShell as a React component in the console document. The shell is written to be its own document: it imports @tailwindcss/browser and its globals.css at import scope, and themes by toggling document.documentElement. Inlined, its palette overwrote the console's grayscale tokens for the rest of the session, and its content-height notifications reached no host, so tall artifacts clipped. The page now loads the built shell document — the same bytes served as ui://executor/shell.html — into a sandboxed iframe and implements the host half of the protocol with ext-apps' AppBridge over PostMessageTransport. The stored source arrives as tool-input plus a tool-result carrying structuredContent.code, exactly as after a render-ui call, so the shell takes one path in every host. callServerTool routes to the existing HTTP mapping, size-changed drives the iframe height, and theme travels as host context. A new mcpAppsShellAsset vite plugin builds the shell and emits it into the client output (client only; the worker never serves it), exporting just its hashed URL so the document stays out of the console's JS. Two shell bugs the host exposed: - The shell registered its one-shot tool-input/tool-result handlers in a mount effect, after connect() had completed the handshake, so a prompt host's delivery was dropped. They are captured in onAppCreated and replayed once the shell has mounted. - The shell capped its height at a hardcoded 800px. It now honors the protocol's containerDimensions and otherwise reports true content height, leaving the frame to the host. e2e asserts the console's tokens and stylesheet count are unchanged after rendering while the shell keeps its own palette one document down, and that a 40-row artifact grows the frame past its initial height. Both guards were mutation-tested. * Connect the artifact shell bridge when the frame mounts, not on load Opening an artifact could leave the shell on "Connecting" forever. ui/initialize is fire-and-forget: the app posts it once, ext-apps has no retry and AppBridge buffers nothing, and the host only starts listening inside bridge.connect(). Connecting on the iframe's onLoad put that listener up after the shell document had already parsed and run its module script, so the request could land on a window with no listener and be dropped, leaving both sides waiting. It passed locally because it won the race by about 5ms. Measured on the running cloud instance, ui/initialize arrived 5.4-6.3ms after the host registered; delaying only that registration by 300ms reproduced the stuck state 3/3. The bridge is now built in the iframe's ref callback, which runs synchronously once React inserts the element and before the browser fetches src. contentWindow is the initial about:blank window there, and a same-origin navigation preserves the WindowProxy, so the transport's target and its event.source filter still match the shell document. The same margin now measures 124-127ms, and the listener is up before the frame is navigated at all. sunpeak's host connects in its ref callback too, which is why real MCP-Apps hosts never hit this. The ref callback's cleanup also replaces the teardown effect keyed on props.code. That effect was a second owner of bridgeRef and could close a bridge a later mount had just connected; each bridge now closes only itself. The e2e asserted only that the artifact eventually rendered, which the old code did on a fast machine. It now asserts the ordering instead: the host's listener must be registered while the shell frame is still about:blank. That fails on the previous implementation, which records the shell document's URL. Stuck-on-Connecting also now fails directly. * Use the prod execution cap for interactive dev instances * Make artifact code purely declarative tools.* Remove the run(code) escape hatch from the model-facing scope. Arbitrary code is opaque to the invalidation helpers (a hand-rolled queryKey defeats queryFilter) and to artifact analysis, which reads tools. references out of the source. Close the pagination gap it was reached for: add .infiniteQueryOptions to the tools proxy, modeled on tRPC v11. The model supplies TanStack's own initialPageParam and getNextPageParam; the proxy merges each page param into the tool input at cursorKey (default "cursor", dotted for nested inputs) and mints the query key so queryFilter invalidation keeps working. Constrain execute-action to the one grammar the proxy emits, return await tools.(), so the wire matches the surface and an iframe cannot smuggle arbitrary code through the app channel. execute is unchanged. render-ui also rejects code calling run(). * Scope artifact deep links to the session's org * Reject React hooks called inside loop bodies in artifact code * Rename the render-ui tool to create-artifact * Style artifacts with executor's design system The shell shipped stock shadcn tokens (a teal primary in a strictly grayscale product), no webfont, and no design guidance for the model. - Ship Geist and Geist Mono, subset and inlined as data: URLs, since the renderer frame's CSP is font-src data:. - Replace the shell theme with executor's own tokens, pinned against the app's stylesheet so drift fails a test. - Add an ordered, desaturated eight-slot chart palette plus status colors, led by a neutral so single-series charts stay grayscale. - Give the renderer frame a Tailwind compiler and the theme as source. It previously received only the compiled stylesheet, so any class the model wrote that executor's own components don't use silently did nothing: text-2xl computed to 16px and md:grid-cols-4 stayed one column. - Add ArtifactLoading / ArtifactEmpty / ArtifactError to generated-code scope and render ArtifactError from the error boundaries instead of a raw stack trace. - Let the shell own the artifact's outer padding and measure. - Add an artifact-style skill carrying the design system, separate from the create-artifact capability manifest. * Fill the viewport when an artifact is shorter than it * Bind artifact tool paths to connections instead of pinning them Artifact code addressed tools by full five-segment address, which welded each saved artifact to its author's connection and made sharing impossible. Artifact source now names an integration — tools.vercel.domains.getDomains — and, when it needs two accounts of one, a role: tools.linear("prod").issues.list. The connection is resolved once at create time against the author's own connections (sole connection binds silently, several is an error listing candidates, or pass a connections map) and stored in a new nullable bindings column. execute-action takes the artifact id, looks up the bindings, and rebuilds the full address server-side, so the iframe never chooses an address. Artifacts saved before this carry bindings: null and run their old paths as written. * Use the integration placeholder in the artifacts e2e comment * Update an artifact in place by passing its id to create-artifact * Reject chart primitives that no ChartContainer wraps * Trial-render an artifact before saving it * Skip the artifact smoke render where the runtime forbids codegen * Cover artifact tweak-in-place and the refused render in e2e * Record artifact updates and create-time render checking * Show artifacts as a preview card grid The artifacts list was a row list, which gave a generated UI no visual presence. It is now a 1/2/3-column gallery: each card leads with a 16:10 preview, then title, description, and the updated time in mono. Artifacts are live components and there are no stored screenshots, so the preview is a deterministic schematic derived from the artifact id rather than a render. Rendering for real is not reachable from here: the shell depends on this package, so it can only arrive through the renderer loader seam, and artifacts.list omits code by design — a grid of N previews would mean N extra fetches for the heavy column the summary exists to avoid, plus two nested iframes per card. ArtifactPreview is the slot a captured screenshot drops into later. * Run the artifact smoke render in a sandbox The create-time render evaluated model-written code with new Function in the trusted server process, where scope shadowing was the only barrier between an artifact and globalThis.process. It now runs in a QuickJS sandbox from the kernel, the same substrate execute uses. React, the component barrel and the tools proxy are prebuilt into a harness bundle that lives inside the sandbox, so the host process holds no React and hands the sandbox nothing but the artifact's source. QuickJS is an interpreter, so new Function inside it never reaches V8's codegen ban - which is what lets cloud run the real check now instead of skipping it, and what removes the React graph that kept apps/local from wiring the check at all. Deletes the new Function capability probe and its skip, and the in-process evaluator. Adds attempted-escape tests: process, the Function constructor route to it, dynamic import and the network are all unreachable, and a spinning component is now preempted at the deadline rather than hanging the request. * Load QuickJS-WASM on cloud Cloud runs user execute code on the dynamic-worker runtime and so never preloaded the QuickJS engine, which host-cloudflare has vendored since Workers cannot fetch a .wasm at runtime. The artifact smoke render is a QuickJS sandbox on every host, so without it every create failed open with 'both async and sync fetching of the wasm failed' and the check silently did nothing. Same vendoring and preload as host-cloudflare. * Leave the generated harness bundle unformatted It is one machine-written string constant, like routeTree.gen.ts. * Forward execute options and settled-check through cloud engine decorators Both cloud engine decorators rebuild the ExecutionEngine object literal member by member. withExecutionUsageTracking declared executeWithPause as (code) and dropped the options argument, so autoApprove never reached the engine on cloud and an auto-approved execution paused anyway. Both also omitted the optional isExecutionSettled, so the host read every settled execution as one that never existed. * Make an artifact approval survive the request that paused it The console artifact page has no MCP client, so the shell reaches the server over POST /executions and POST /executions/:id/resume. On a host that builds an executor per request those two calls land on different engine instances, and a paused execution lives only in the engine's in-memory map — so the resume never found it and failed with ExecutionNotFoundError. The failure was immediate, not a lease expiring: a resume issued 7ms after the pause failed identically. An artifact action is reconstructible where general code is not. The shell grammar is exactly one tool call and the approval gate runs before the tool is invoked, so a paused artifact action holds no partial work, only a resolved call that has not happened yet. Record that call as a durable pending approval in the existing owner-scoped blob table and honour it when the approval arrives. Records are single-use and expire, and a resume that finds neither a live pause nor a record now reports an expired approval rather than an unknown execution. * Cover approving a destructive action from an artifact The existing approval scenarios all run over MCP, where the engine lives for the lifetime of a session Durable Object, so none of them could see the console's own HTTP path breaking. Adds an artifact-scoped approval scenario (approve after a 65s human-scale wait, and decline) that fails on the pre-fix code with the reported ExecutionNotFoundError, plus unit coverage for the pending-approval store: single use, owner isolation, expiry, and unparseable records. Unskips the run-panel autoApprove scenario, which was blocked on the dropped options argument in the cloud usage-tracking decorator. * Pass scenario options to the unskipped run-panel scenario * Degrade an expired approval into a next action A resume whose approval window closed came back as the transport's error body, so the person clicking Approve read a raw ExecutionNotFoundError inside a JSON-RPC envelope. The server now distinguishes that case with a 410, and the shell's HTTP host renders it as what it is: nothing ran, and the action can be triggered again. Adds the adapter's first tests. * Say what the approval modal is for, and let a tool be trusted once The modal explained itself in terms of our own plumbing ('handled by the Executor shell') rather than telling the user what they had just done. It now says a destructive action was triggered and asks them to confirm, keeping the concrete request preview. Three buttons became two. Cancel and Decline were the same answer twice: the approval gate raises ElicitationDeclinedError for any non-accept action and only the rendered word differs, so Cancel sends 'cancel'. Approve is now a split button whose menu offers approving without being asked again for that tool on that artifact, remembered in the console origin's storage and answered before the modal mounts. A schema whose required fields have no defaults still asks, rather than resuming with values the user never gave. * Keep the smoke render's markup instead of discarding it The create-time render already drives a full server render with a transport that never settles, so its output is the artifact's loading composition with no data in it. Keep it, bounded, as the basis for a real gallery preview. * Add an allowlist sanitizer for artifact preview markup Only known-inert elements and attributes survive: no script, style element, image, frame, handler or external reference. Ids are namespaced so a preview cannot collide with the host document, and the pass is idempotent so Tailwind's arbitrary variants are not mangled by double-escaping. * Store an artifact preview on the row One nullable text column, carried in the list projection because the gallery is the surface that draws it. Markup and image previews share the column and are told apart by shape. The preview is rewritten on every save for the same reason bindings are: it interprets the source. * Save a layout preview when an artifact is created The render that validates the artifact now also previews it. Fails open exactly as the verdict does: a preview that cannot be produced or sanitized leaves the card on its schematic, never refuses the create. Also adds PUT /artifacts/:id/preview for the settled-render snapshot, owner-scoped and image-only. * Draw the artifact's real layout on its gallery card Image beats layout beats schematic, so there is always something to show. The markup is inserted directly rather than into an iframe: it is our own allowlist-sanitized server render, and a srcdoc document would inherit neither the console's stylesheet nor its fonts, so every card would have to carry both. * Upgrade the preview to real pixels once an artifact settles The inner frame waits for its queries to go quiet, rasterizes itself through an SVG foreignObject, and hands the snapshot back. Only where the host advertises somewhere to put one, so a real MCP client never does the work. Best-effort throughout: a failed capture leaves the layout preview in place. * Read the host through a ref when relaying a preview Keeps the renderer message handler built once, and drops the bare promise catch the lint rule forbids. * Keep the shell's build graph out of the console bundle The capture's shared names now live in a module with no imports. Taking them from shell-app pulled its virtual modules — the in-frame Tailwind compiler and inner renderer — into the console, where nothing supplies them, and the artifact renderer failed to load at all. * Measure the preview scale instead of computing it in CSS A container-query divisor produces a length, and scale() takes a number, so the browser dropped the transform and drew the markup at 1:1 — a card showed the top-left corner of an artifact rather than the artifact. Measured against the card, which is fluid anyway. * Compile the layout tracks a preview needs The gallery draws stored markup in the console document, so there is no in-browser Tailwind to fall back on. The console never needs a four-column stat row; a generated dashboard almost always does, and without the class it collapsed to one column. * Scan the document, not the bundle, for external references The shell inlines ~5MB of JavaScript that includes React source strings building markup as text, so matching the raw file reported tags that exist only inside string literals. Blanking inline script bodies first — while keeping any script that carries a src, which is the regression being guarded — reports what the document actually references. Mutation-tested against a sibling bundle, a stylesheet link, and a remote font. * Capture the settled render as markup, not pixels Rasterizing was the intent and cannot work: the render frame is sandboxed without allow-same-origin, so it runs in an opaque origin, every image it can build taints the canvas, and toDataURL throws. Measured, not assumed — a 203-byte inline SVG fails exactly as the real one does, so nothing short of giving the frame a real origin fixes it, and that origin is the sandbox. Capturing the settled DOM keeps what the upgrade was for: the card shows the artifact with its real numbers instead of its skeleton. Both previews are now markup through one sanitizer, so the console renders them identically. * Give an artifact a viewport to lay out against An artifact rendered on the console's detail page IS the page, but the shell only ever ran one way: the inner renderer reports its content height, the host grows the frame, and the console page scrolls. A generated dashboard with 143 rows therefore pushed its own header, search box and filters off the top of the screen, and no amount of `h-full` in the artifact could hold them there — `h-full` against an unbounded parent is just `h-auto`. Inline growth stays exactly as it is for chat hosts, where an artifact is one block in a scrolling transcript. The detail page now negotiates the other mode through the MCP Apps host context: `displayMode: "fullscreen"` plus a fixed `containerDimensions.height`, both spec fields, since `McpUiDisplayMode` is a closed union with no room for one of our own. The host measures its container with a ResizeObserver, sizes the frame to it, and ignores the app's size reports; the app keeps reporting them unconditionally, which is what keeps the mode entirely the host's business. The rest is the height chain, which had to hold at every link: content area, outer frame, shell document, inner srcdoc frame, `#root`, and the artifact's own root element. `min-height: 100%` is what a document uses to grow; a definite `height: 100%` is what a percentage child resolves against, so fill mode swaps to one under a document class and makes `.artifact-root` a flex column its child can stretch inside. * Teach the app layout an artifact can now use The style skill previously banned the pattern outright — "never a nested scroll container", "never position: sticky" — which was honest advice when the frame always grew to content and the page always scrolled. With a bounded viewport those become the right answer rather than the wrong one, so the bans are narrowed to what is still true: no `fixed`, no hardcoded scroll heights, no viewport units. In their place is the pattern itself: root `flex h-full flex-col`, chrome as ordinary children, one data region as `flex-1 min-h-0 overflow-auto`, and a `sticky top-0` table header with a background inside it. The `min-h-0` gets its own note because omitting it is the failure that looks like the rule not working. `config.maxHeight` keeps its inline meaning and is documented as ignored where the artifact already has the whole viewport. The preview sanitizer carries the whole pattern in class names, so a test pins that it does — a card is drawn from sanitized markup, and silently dropping `flex-1` there would show a layout the artifact does not have. * Pin both sides of the display-mode contract in the browser Two tests against a real Chromium, using the layout the style skill tells a model to write: a 120-row table in a `flex-1 min-h-0 overflow-auto` region under a header. In fill mode, three things have to hold together, because any one alone can be true while the layout is still broken: the region overflows a bounded height, scrolling it really moves the rows, and neither the artifact's header nor its sticky table header moves while they do. The sticky header is compared against its own starting position rather than the container's edge — `sticky` pins to the padding box, so the 1px border makes the naive assertion off by one. In inline mode the shell must still grow past the frame and report it, which is what every chat host depends on. The harness gained a way to send a host-context-change notification carrying only the changed fields, which is how a real host announces a mode and a measured viewport after the handshake. * Assert the detail page behaves like an app, not a document The tall-artifact step asserted the old contract directly — "the host grew the iframe past its initial height", frame height > 600 — which is precisely the behaviour that made a long table push its own heading off the screen. Left as-is it would have failed against the fix while describing the bug as correct. Rewritten against what the page now promises, and the fixture with it: the artifact is laid out the way the style skill teaches, the frame is bounded by the window rather than by its content, the console page does not scroll at all, and the artifact's header is still exactly where it was after its rows have been scrolled 300px. * Add an artifacts opt-out for MCP connections Sessions that connect with ?artifacts=false (--no-artifacts on the stdio CLI) get no artifact surface: none of the five artifact tools, no ui:// shell resource, and no artifact entries in the skills inventory. On by default; the install card's Advanced section carries the toggle. * Hold one loading surface from navigation to rendered artifact Opening an artifact walked through three placeholders in a row — the row fetch, the lazy renderer chunk, and the shell's own boot — each in its own layout and each visible for a few hundred milliseconds. Those are internal boundaries, not facts a viewer needs, and crossing them was visible only as churn. Now one skeleton mounts with the route and stays until the artifact has painted, with every boundary crossed underneath it. Where the artifact has a stored gallery preview it waits against its own layout, seeded from the list cache so it is right on the first frame after a click; otherwise hairline blocks in the artifact's own framing. The renderer chunk is preloaded from the gallery and alongside the row fetch, so its placeholder never appears. The shell reports when the artifact has actually painted — nothing on the console side can see two documents down — and the skeleton cross-fades out on that signal. A host that holds its own surface no longer gets the shell's card underneath it. Error surfaces replace the skeleton rather than stacking on it, and the frame is mounted at full size behind it throughout so fill-mode sizing never jumps. * Land the skeleton on the artifact's own pixels The preview-backed skeleton scaled its markup to a fixed 880px, so it sat 88px left of where the artifact resolved and every element slid into place at the handoff. The stage has the artifact's real room, so there is nothing to scale: mirroring .artifact-root's framing lays the same markup out at exactly the width the artifact is about to occupy. The generic skeleton distributes its rows down the region rather than stacking them under the header, since an artifact given the viewport fills it. * Pin both sides of the render-signal contract in a real browser The signal is what lets the console take its skeleton down at the right moment, so both directions need holding: a host that asks is told once the artifact has painted and only once, even when a second artifact is delivered into the same shell; a host that never asks is untouched and keeps the shell's own loading card. The second half is the guard for every real MCP client, and the harness's default context omits the key exactly as Claude's does. * Report a paint the host asked about after the fact The paint travels up from the inner frame and the "tell me when it happens" capability travels down on the host context, over independent channels whose order is not guaranteed. A host that advertises it in a later host-context-changed rather than its initialize reply arrives second, and the paint never happens twice — so testing the capability at the moment of the paint dropped the only notification there would ever be, leaving that host's loading surface up over a rendered artifact. The paint is remembered instead, and whichever of the two arrives second sends the call. Still latched, so it stays exactly once. * Wait on one blank block instead of the last saved render The skeleton drew the artifact's stored preview scaled to fill the stage, then cross-faded it out. Both were attempts to predict a layout that is arbitrary user code: the preview is a render of a PREVIOUS visit, so what faded in was never quite what had been held, and the eye tracked every piece that moved. One rounded block over the stage region makes no claim it can break, and the handoff is a hard swap on the paint signal rather than a 150ms interpolation between two things that share no shapes. The paint signal, the renderer preload and the single-surface structure are unchanged; the stored-preview peek is gone with its only consumer. * Make MCP artifacts opt-in per connection * Fix artifact shell production packaging * Defer artifact shell dev build * Load shell bundler on demand --- .changeset/olive-crabs-repeat.md | 5 + .oxfmtrc.json | 1 + .oxlintrc.jsonc | 25 + apps/cli/src/build.ts | 31 + apps/cli/src/main.ts | 52 +- .../cloud/drizzle/0013_curly_rocket_racer.sql | 15 + apps/cloud/drizzle/0014_new_karma.sql | 1 + apps/cloud/drizzle/0015_equal_the_leader.sql | 1 + apps/cloud/drizzle/meta/0013_snapshot.json | 1474 +++++++++ apps/cloud/drizzle/meta/0014_snapshot.json | 1480 +++++++++ apps/cloud/drizzle/meta/0015_snapshot.json | 1486 +++++++++ apps/cloud/drizzle/meta/_journal.json | 21 + apps/cloud/package.json | 6 +- apps/cloud/scripts/vendor-quickjs-wasm.ts | 18 + apps/cloud/src/db/executor-schema.ts | 22 + apps/cloud/src/db/org-deletion.test.ts | 13 + apps/cloud/src/db/org-deletion.ts | 2 + apps/cloud/src/engine/execution-gate.ts | 4 + apps/cloud/src/engine/execution-usage.ts | 7 +- apps/cloud/src/mcp/agent-handler.ts | 2 + apps/cloud/src/mcp/session-durable-object.ts | 24 + apps/cloud/src/quickjs-engine.wasm | Bin 0 -> 518880 bytes apps/cloud/src/quickjs.ts | 35 + apps/cloud/src/routeTree.gen.ts | 65 +- apps/cloud/src/routes/__root.tsx | 12 +- apps/cloud/src/wasm.d.ts | 6 + apps/cloud/vite.config.ts | 8 + apps/host-cloudflare/package.json | 1 + apps/host-cloudflare/src/mcp/agent-handler.ts | 2 + .../src/mcp/session-durable-object.ts | 21 +- apps/host-cloudflare/tsconfig.json | 4 + apps/host-cloudflare/vite.config.ts | 9 +- apps/host-cloudflare/web/routeTree.gen.ts | 61 + apps/host-cloudflare/web/routes/__root.tsx | 16 +- apps/host-selfhost/package.json | 1 + apps/host-selfhost/src/mcp/session-store.ts | 4 + apps/host-selfhost/tsconfig.json | 4 + apps/host-selfhost/vite.config.ts | 7 + apps/host-selfhost/web/routeTree.gen.ts | 61 + apps/host-selfhost/web/routes/__root.tsx | 16 +- apps/local/package.json | 1 + apps/local/src/executor.ts | 7 +- apps/local/src/main.ts | 47 +- apps/local/src/mcp.ts | 2 + bun.lock | 56 + e2e/mcp-apps/.gitignore | 5 + e2e/mcp-apps/README.md | 67 + e2e/mcp-apps/package.json | 17 + e2e/mcp-apps/playwright.config.ts | 31 + e2e/mcp-apps/scripts/check-sunpeak.mjs | 45 + e2e/mcp-apps/tests/create-artifact.spec.ts | 87 + e2e/scenarios/artifact-approval.test.ts | 229 ++ .../artifact-loading-surface.test.ts | 330 ++ .../artifact-preview-gallery.test.ts | 281 ++ e2e/scenarios/artifacts.test.ts | 830 ++++++ e2e/scenarios/run-panel-auto-approve.test.ts | 29 +- e2e/scripts/cli.ts | 5 + e2e/src/surfaces/mcp.ts | 24 +- package.json | 2 +- packages/app/package.json | 1 + packages/app/src/routeTree.gen.ts | 58 + packages/app/src/routes/__root.tsx | 16 +- packages/app/src/web/shell.tsx | 7 + packages/app/vite.ts | 7 + packages/core/api/src/account/org-slug.ts | 6 +- packages/core/api/src/api.ts | 2 + packages/core/api/src/artifacts/api.ts | 165 + packages/core/api/src/executions/api.ts | 53 +- packages/core/api/src/handlers/artifacts.ts | 107 + packages/core/api/src/handlers/executions.ts | 197 +- packages/core/api/src/handlers/index.ts | 3 + packages/core/api/src/index.ts | 1 + packages/core/api/src/server.ts | 1 + packages/core/api/src/server/mcp-build.ts | 58 +- packages/core/execution/src/index.ts | 11 +- .../core/execution/src/provided-globals.ts | 274 ++ packages/core/execution/src/skills.test.ts | 19 +- packages/core/execution/src/skills.ts | 561 +++- .../core/sdk/src/artifact-preview.test.ts | 304 ++ packages/core/sdk/src/artifact-preview.ts | 574 ++++ packages/core/sdk/src/artifact-table.test.ts | 132 + packages/core/sdk/src/artifact.ts | 211 ++ packages/core/sdk/src/artifacts.test.ts | 353 +++ packages/core/sdk/src/core-schema.ts | 64 + packages/core/sdk/src/errors.ts | 27 +- packages/core/sdk/src/executor.ts | 191 ++ packages/core/sdk/src/ids.ts | 4 + packages/core/sdk/src/index.ts | 29 + .../core/sdk/src/pending-approval.test.ts | 125 + packages/core/sdk/src/pending-approval.ts | 110 + packages/core/sdk/src/shared.ts | 16 + .../src/mcp/agent-session-durable-object.ts | 9 +- .../hosts/cloudflare/src/mcp/do-headers.ts | 9 +- packages/hosts/mcp-apps-shell/.gitignore | 1 + packages/hosts/mcp-apps-shell/CHANGELOG.md | 1 + packages/hosts/mcp-apps-shell/package.json | 98 + .../scripts/gen-provided-globals.ts | 23 + .../scripts/gen-smoke-harness.ts | 45 + .../mcp-apps-shell/scripts/subset-fonts.ts | 96 + packages/hosts/mcp-apps-shell/src/index.ts | 11 + .../hosts/mcp-apps-shell/src/shell-html.ts | 49 + .../src/shell-resource.smoke.test.ts | 91 + .../src/shell/artifact-renderer.tsx | 432 +++ .../src/shell/artifact-states.tsx | 217 ++ .../src/shell/component-runtime.test.ts | 53 + .../src/shell/component-runtime.ts | 190 ++ .../mcp-apps-shell/src/shell/components.ts | 283 ++ .../mcp-apps-shell/src/shell/display-mode.ts | 104 + .../mcp-apps-shell/src/shell/fonts/README.md | 29 + .../src/shell/fonts/geist-mono.woff2 | Bin 0 -> 20284 bytes .../src/shell/fonts/geist-sans.woff2 | Bin 0 -> 25548 bytes .../mcp-apps-shell/src/shell/globals.css | 43 + .../hosts/mcp-apps-shell/src/shell/hooks.ts | 158 + .../src/shell/inner-renderer-source.d.ts | 14 + .../src/shell/inner-renderer.tsx | 432 +++ .../src/shell/mcp-app.browser.test.ts | 2647 +++++++++++++++++ .../mcp-apps-shell/src/shell/mcp-app.html | 12 + .../mcp-apps-shell/src/shell/mcp-app.tsx | 101 + .../src/shell/preview-capture.ts | 36 + .../src/shell/provided-globals.pin.test.ts | 40 + .../hosts/mcp-apps-shell/src/shell/proxy.ts | 181 ++ .../mcp-apps-shell/src/shell/render-signal.ts | 60 + .../mcp-apps-shell/src/shell/shell-app.tsx | 1098 +++++++ .../src/shell/shell-html-url.d.ts | 13 + .../src/shell/smoke-harness-bundle.gen.ts | 17 + .../src/shell/smoke-harness-shims.ts | 274 ++ .../src/shell/smoke-harness.tsx | 168 ++ .../src/shell/smoke-render-result.ts | 36 + .../src/shell/smoke-render.test.tsx | 388 +++ .../mcp-apps-shell/src/shell/smoke-render.ts | 189 ++ .../src/shell/theme-tokens.pin.test.ts | 196 ++ .../hosts/mcp-apps-shell/src/shell/theme.css | 317 ++ .../src/shell/tool-call-grammar.pin.test.ts | 110 + .../mcp-apps-shell/src/shell/tools-proxy.ts | 261 ++ .../mcp-apps-shell/src/smoke-render-entry.ts | 43 + .../hosts/mcp-apps-shell/src/vite.test.ts | 17 + packages/hosts/mcp-apps-shell/src/vite.ts | 363 +++ packages/hosts/mcp-apps-shell/tsconfig.json | 15 + .../hosts/mcp-apps-shell/vite.config.shell.ts | 46 + .../hosts/mcp-apps-shell/vitest.config.ts | 12 + packages/hosts/mcp/package.json | 13 + packages/hosts/mcp/src/artifact-action.ts | 87 + .../hosts/mcp/src/artifact-bindings.test.ts | 398 +++ packages/hosts/mcp/src/artifact-bindings.ts | 419 +++ .../hosts/mcp/src/artifacts-tools.test.ts | 1953 ++++++++++++ packages/hosts/mcp/src/browser-approval.ts | 14 + packages/hosts/mcp/src/create-artifact.ts | 407 +++ .../hosts/mcp/src/in-memory-session-store.ts | 9 +- packages/hosts/mcp/src/tool-call-code.ts | 119 + packages/hosts/mcp/src/tool-server.ts | 868 +++++- packages/kernel/runtime-quickjs/src/index.ts | 20 + .../runtime-quickjs/src/sealed-bundle.test.ts | 113 + .../runtime-quickjs/src/sealed-bundle.ts | 209 ++ packages/react/package.json | 3 + packages/react/src/api/analytics.tsx | 6 + .../react/src/api/artifact-renderer.test.tsx | 93 + packages/react/src/api/artifact-renderer.tsx | 180 ++ packages/react/src/api/atoms.tsx | 68 + packages/react/src/api/reactivity-keys.tsx | 6 + packages/react/src/api/shell-host.test.ts | 111 + packages/react/src/api/shell-host.ts | 219 ++ .../src/components/artifact-preview.test.tsx | 65 + .../react/src/components/artifact-preview.tsx | 472 +++ .../artifact-stage-skeleton.test.tsx | 61 + .../components/artifact-stage-skeleton.tsx | 68 + .../src/components/elicitation-approval.tsx | 17 + .../src/components/mcp-install-card.test.ts | 47 + .../react/src/components/mcp-install-card.tsx | 44 +- packages/react/src/console-routes.ts | 4 + packages/react/src/lib/relative-time.test.ts | 37 + packages/react/src/lib/relative-time.ts | 19 + packages/react/src/multiplayer/shell.tsx | 1 + packages/react/src/pages/artifact-detail.tsx | 327 ++ .../src/pages/artifact-rename-dialog.tsx | 109 + packages/react/src/pages/artifacts.tsx | 252 ++ packages/react/src/routes/artifacts-route.tsx | 15 + .../src/routes/artifacts.$artifactId.tsx | 12 + packages/react/src/routes/artifacts.tsx | 15 + packages/react/src/routes/routeTree.gen.ts | 53 + packages/react/src/styles/globals.css | 26 + plans/artifacts.md | 277 ++ 181 files changed, 26964 insertions(+), 105 deletions(-) create mode 100644 .changeset/olive-crabs-repeat.md create mode 100644 apps/cloud/drizzle/0013_curly_rocket_racer.sql create mode 100644 apps/cloud/drizzle/0014_new_karma.sql create mode 100644 apps/cloud/drizzle/0015_equal_the_leader.sql create mode 100644 apps/cloud/drizzle/meta/0013_snapshot.json create mode 100644 apps/cloud/drizzle/meta/0014_snapshot.json create mode 100644 apps/cloud/drizzle/meta/0015_snapshot.json create mode 100644 apps/cloud/scripts/vendor-quickjs-wasm.ts create mode 100644 apps/cloud/src/quickjs-engine.wasm create mode 100644 apps/cloud/src/quickjs.ts create mode 100644 apps/cloud/src/wasm.d.ts create mode 100644 e2e/mcp-apps/.gitignore create mode 100644 e2e/mcp-apps/README.md create mode 100644 e2e/mcp-apps/package.json create mode 100644 e2e/mcp-apps/playwright.config.ts create mode 100644 e2e/mcp-apps/scripts/check-sunpeak.mjs create mode 100644 e2e/mcp-apps/tests/create-artifact.spec.ts create mode 100644 e2e/scenarios/artifact-approval.test.ts create mode 100644 e2e/scenarios/artifact-loading-surface.test.ts create mode 100644 e2e/scenarios/artifact-preview-gallery.test.ts create mode 100644 e2e/scenarios/artifacts.test.ts create mode 100644 packages/core/api/src/artifacts/api.ts create mode 100644 packages/core/api/src/handlers/artifacts.ts create mode 100644 packages/core/execution/src/provided-globals.ts create mode 100644 packages/core/sdk/src/artifact-preview.test.ts create mode 100644 packages/core/sdk/src/artifact-preview.ts create mode 100644 packages/core/sdk/src/artifact-table.test.ts create mode 100644 packages/core/sdk/src/artifact.ts create mode 100644 packages/core/sdk/src/artifacts.test.ts create mode 100644 packages/core/sdk/src/pending-approval.test.ts create mode 100644 packages/core/sdk/src/pending-approval.ts create mode 100644 packages/hosts/mcp-apps-shell/.gitignore create mode 100644 packages/hosts/mcp-apps-shell/CHANGELOG.md create mode 100644 packages/hosts/mcp-apps-shell/package.json create mode 100644 packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts create mode 100644 packages/hosts/mcp-apps-shell/scripts/gen-smoke-harness.ts create mode 100644 packages/hosts/mcp-apps-shell/scripts/subset-fonts.ts create mode 100644 packages/hosts/mcp-apps-shell/src/index.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell-html.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/shell/artifact-states.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/components.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/display-mode.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/fonts/README.md create mode 100644 packages/hosts/mcp-apps-shell/src/shell/fonts/geist-mono.woff2 create mode 100644 packages/hosts/mcp-apps-shell/src/shell/fonts/geist-sans.woff2 create mode 100644 packages/hosts/mcp-apps-shell/src/shell/globals.css create mode 100644 packages/hosts/mcp-apps-shell/src/shell/hooks.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/mcp-app.html create mode 100644 packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/proxy.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/render-signal.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/shell/shell-html-url.d.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/smoke-harness-bundle.gen.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/smoke-harness-shims.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/shell/smoke-render-result.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/smoke-render.test.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/shell/smoke-render.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/theme-tokens.pin.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/theme.css create mode 100644 packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/tools-proxy.ts create mode 100644 packages/hosts/mcp-apps-shell/src/smoke-render-entry.ts create mode 100644 packages/hosts/mcp-apps-shell/src/vite.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/vite.ts create mode 100644 packages/hosts/mcp-apps-shell/tsconfig.json create mode 100644 packages/hosts/mcp-apps-shell/vite.config.shell.ts create mode 100644 packages/hosts/mcp-apps-shell/vitest.config.ts create mode 100644 packages/hosts/mcp/src/artifact-action.ts create mode 100644 packages/hosts/mcp/src/artifact-bindings.test.ts create mode 100644 packages/hosts/mcp/src/artifact-bindings.ts create mode 100644 packages/hosts/mcp/src/artifacts-tools.test.ts create mode 100644 packages/hosts/mcp/src/create-artifact.ts create mode 100644 packages/hosts/mcp/src/tool-call-code.ts create mode 100644 packages/kernel/runtime-quickjs/src/sealed-bundle.test.ts create mode 100644 packages/kernel/runtime-quickjs/src/sealed-bundle.ts create mode 100644 packages/react/src/api/artifact-renderer.test.tsx create mode 100644 packages/react/src/api/artifact-renderer.tsx create mode 100644 packages/react/src/api/shell-host.test.ts create mode 100644 packages/react/src/api/shell-host.ts create mode 100644 packages/react/src/components/artifact-preview.test.tsx create mode 100644 packages/react/src/components/artifact-preview.tsx create mode 100644 packages/react/src/components/artifact-stage-skeleton.test.tsx create mode 100644 packages/react/src/components/artifact-stage-skeleton.tsx create mode 100644 packages/react/src/lib/relative-time.test.ts create mode 100644 packages/react/src/lib/relative-time.ts create mode 100644 packages/react/src/pages/artifact-detail.tsx create mode 100644 packages/react/src/pages/artifact-rename-dialog.tsx create mode 100644 packages/react/src/pages/artifacts.tsx create mode 100644 packages/react/src/routes/artifacts-route.tsx create mode 100644 packages/react/src/routes/artifacts.$artifactId.tsx create mode 100644 packages/react/src/routes/artifacts.tsx create mode 100644 plans/artifacts.md diff --git a/.changeset/olive-crabs-repeat.md b/.changeset/olive-crabs-repeat.md new file mode 100644 index 0000000000..79f7beeae9 --- /dev/null +++ b/.changeset/olive-crabs-repeat.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Add an Artifacts tab. Interactive components an agent generates with `render-ui` are saved and listed in the console, and each one has its own page that renders it live — the page an MCP client without MCP Apps support deep-links to. Artifacts can be renamed and deleted from the console, and agents find them again by title. diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d92df54ec7..73f7255814 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -13,6 +13,7 @@ "*.tsbuildinfo", "executor-*.tgz", "**/routeTree.gen.ts", + "**/smoke-harness-bundle.gen.ts", "apps/cloud/src/services/executor-schema.ts" ] } diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 9c7d8caf1f..da06c53517 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -119,6 +119,31 @@ "executor/no-try-catch-or-throw": "off", }, }, + { + // boundary: the MCP-Apps shell is browser code, not Effect domain code. + // It runs inside a sandboxed iframe and talks to the host over + // postMessage and the MCP bridge — both of which are throw/Promise APIs + // with no Effect runtime present. `new Function` evaluation of generated + // JSX, CSS harvesting off `document.styleSheets`, and JSON on the wire + // all surface untyped JS errors that must be caught and rendered rather + // than modelled. Scoped to the iframe payload plus its build config; the + // package's Effect-facing loader (`src/shell-html.ts`) is deliberately + // NOT listed and uses inline suppressions instead. + "files": [ + "packages/hosts/mcp-apps-shell/src/shell/**/*.{ts,tsx}", + "packages/hosts/mcp-apps-shell/vite.config.shell.ts", + ], + "rules": { + "executor/no-double-cast": "off", + "executor/no-error-constructor": "off", + "executor/no-instanceof-error": "off", + "executor/no-json-parse": "off", + "executor/no-promise-catch": "off", + "executor/no-promise-reject": "off", + "executor/no-try-catch-or-throw": "off", + "executor/no-unknown-error-message": "off", + }, + }, { "files": ["packages/kernel/core/src/code-recovery.ts"], "rules": { diff --git a/apps/cli/src/build.ts b/apps/cli/src/build.ts index 64117dc861..1fc7ad9372 100644 --- a/apps/cli/src/build.ts +++ b/apps/cli/src/build.ts @@ -25,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { return wasmPath; }; +// --------------------------------------------------------------------------- +// MCP-Apps shell (mcp-app.html) +// +// The MCP host serves the shell as the `ui://executor/shell.html` resource, and +// `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime +// `fs.readFile` is invisible to `bun build --compile`, so without this the +// packaged binary would serve the "Shell not built" placeholder and every +// generated UI would render blank. Build it if missing and copy it next to the +// executable, where the loader finds it via `process.execPath` — the same +// colocation trick used for `libsql.node` / `emscripten-module.wasm`. +// --------------------------------------------------------------------------- + +const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell"); +const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html"); + +const ensureMcpAppsShell = async (): Promise => { + if (!existsSync(mcpAppsShellHtml)) { + console.log("Building MCP-Apps shell (mcp-app.html)..."); + await $`bun run --cwd ${mcpAppsShellDir} build:shell`.quiet(); + } + if (!existsSync(mcpAppsShellHtml)) { + throw new Error(`MCP-Apps shell missing at ${mcpAppsShellHtml} after build:shell`); + } + return mcpAppsShellHtml; +}; + const resolveOnePasswordCoreWasmPath = (): string => { const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json")); const sdkPkg = req.resolve("@1password/sdk/package.json"); @@ -398,6 +424,7 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`); const quickJsWasmPath = resolveQuickJsWasmPath(); + const mcpAppsShellPath = await ensureMcpAppsShell(); const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); const workerBundlerDistPath = resolveWorkerBundlerDistPath(); const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath); @@ -423,6 +450,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { // Copy QuickJS WASM next to binary — loaded at runtime by the server await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm")); + // Copy the MCP-Apps shell next to the binary. Platform-independent HTML, + // read from execDir at runtime by @executor-js/mcp-apps-shell. + await cp(mcpAppsShellPath, join(binDir, "mcp-app.html")); + // Copy 1Password's sdk-core WASM next to the binary. The patched // sdk-core loader falls back to this sibling file when bun --compile // bakes a build-machine node_modules path into __dirname. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index bae18617c0..06ca6586d0 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -1337,14 +1337,19 @@ const runBackgroundDaemonStart = (input: { // Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon. // --------------------------------------------------------------------------- -const mcpUrlForActiveLocalServer = ( - connection: ExecutorServerConnection, - elicitationMode: "browser" | "model", -): URL => { - const url = new URL("/mcp", connection.origin); - if (elicitationMode === "browser") { +const mcpUrlForActiveLocalServer = (input: { + readonly connection: ExecutorServerConnection; + readonly elicitationMode: "browser" | "model"; + readonly artifacts: boolean; +}): URL => { + const url = new URL("/mcp", input.connection.origin); + if (input.elicitationMode === "browser") { url.searchParams.set("elicitation_mode", "browser"); } + // Only the opt-in is spelled out; the default endpoint stays clean. + if (input.artifacts) { + url.searchParams.set("artifacts", "true"); + } return url; }; @@ -1359,11 +1364,16 @@ const mcpUrlForActiveLocalServer = ( const runMcpHttpBridge = async (input: { readonly manifest: ExecutorLocalServerManifest; readonly elicitationMode: "browser" | "model"; + readonly artifacts: boolean; }): Promise => { const stdio = new StdioServerTransport(); const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection); const http = new StreamableHTTPClientTransport( - mcpUrlForActiveLocalServer(input.manifest.connection, input.elicitationMode), + mcpUrlForActiveLocalServer({ + connection: input.manifest.connection, + elicitationMode: input.elicitationMode, + artifacts: input.artifacts, + }), authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined, ); @@ -1438,7 +1448,10 @@ const runMcpHttpBridge = async (input: { } }; -const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) => +const runStdioMcpSession = (input: { + readonly elicitationMode: "browser" | "model"; + readonly artifacts: boolean; +}) => Effect.gen(function* () { // `executor mcp` never owns the local database. If a local server is already // running, bridge this stdio client to it; otherwise ensure a durable @@ -1450,7 +1463,11 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode const active = yield* readActiveLocalServerManifest(); if (active) { yield* Effect.promise(() => - runMcpHttpBridge({ manifest: active, elicitationMode: input.elicitationMode }), + runMcpHttpBridge({ + manifest: active, + elicitationMode: input.elicitationMode, + artifacts: input.artifacts, + }), ); return; } @@ -1472,7 +1489,11 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode ); } yield* Effect.promise(() => - runMcpHttpBridge({ manifest: elected, elicitationMode: input.elicitationMode }), + runMcpHttpBridge({ + manifest: elected, + elicitationMode: input.elicitationMode, + artifacts: input.artifacts, + }), ); }); @@ -2781,11 +2802,18 @@ const mcpCommand = Command.make( "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.", ), ), + artifacts: Options.boolean("artifacts") + .pipe(Options.withDefault(false)) + .pipe( + Options.withDescription( + "Serve the artifact surface on this connection: the artifact tools, the app shell resource, and the artifact skills. Withheld by default.", + ), + ), }, - ({ scope, elicitationMode }) => + ({ scope, elicitationMode, artifacts }) => Effect.gen(function* () { applyScope(scope); - yield* runStdioMcpSession({ elicitationMode }); + yield* runStdioMcpSession({ elicitationMode, artifacts }); }), ).pipe(Command.withDescription("Start an MCP server over stdio")); diff --git a/apps/cloud/drizzle/0013_curly_rocket_racer.sql b/apps/cloud/drizzle/0013_curly_rocket_racer.sql new file mode 100644 index 0000000000..022df47b98 --- /dev/null +++ b/apps/cloud/drizzle/0013_curly_rocket_racer.sql @@ -0,0 +1,15 @@ +CREATE TABLE "artifact" ( + "id" varchar(255) NOT NULL, + "title" text NOT NULL, + "description" text, + "code" text NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +ALTER TABLE "definition" ALTER COLUMN "name" SET DATA TYPE varchar(255);--> statement-breakpoint +CREATE UNIQUE INDEX "artifact_uidx" ON "artifact" USING btree ("tenant","owner","subject","id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/0014_new_karma.sql b/apps/cloud/drizzle/0014_new_karma.sql new file mode 100644 index 0000000000..606cbfd19b --- /dev/null +++ b/apps/cloud/drizzle/0014_new_karma.sql @@ -0,0 +1 @@ +ALTER TABLE "artifact" ADD COLUMN "bindings" json; \ No newline at end of file diff --git a/apps/cloud/drizzle/0015_equal_the_leader.sql b/apps/cloud/drizzle/0015_equal_the_leader.sql new file mode 100644 index 0000000000..585f98e027 --- /dev/null +++ b/apps/cloud/drizzle/0015_equal_the_leader.sql @@ -0,0 +1 @@ +ALTER TABLE "artifact" ADD COLUMN "preview" text; \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0013_snapshot.json b/apps/cloud/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000000..0947b138ce --- /dev/null +++ b/apps/cloud/drizzle/meta/0013_snapshot.json @@ -0,0 +1,1474 @@ +{ + "id": "edc90067-822c-443a-8fcd-3bac77bca317", + "prevId": "97114994-f0fc-4419-80bf-21c529a6cf0b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0014_snapshot.json b/apps/cloud/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000000..6d33454690 --- /dev/null +++ b/apps/cloud/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1480 @@ +{ + "id": "0857c83f-0124-4739-bd88-a0a4a92aab84", + "prevId": "edc90067-822c-443a-8fcd-3bac77bca317", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0015_snapshot.json b/apps/cloud/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000000..8aaf1637be --- /dev/null +++ b/apps/cloud/drizzle/meta/0015_snapshot.json @@ -0,0 +1,1486 @@ +{ + "id": "d666b31a-c3d1-4bd7-9bd6-85f2abc4fb55", + "prevId": "0857c83f-0124-4739-bd88-a0a4a92aab84", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 49c3e29fae..fa90570831 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -92,6 +92,27 @@ "when": 1785193313601, "tag": "0012_woozy_shiva", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1785354087138, + "tag": "0013_curly_rocket_racer", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1785355312973, + "tag": "0014_new_karma", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1785355354955, + "tag": "0015_equal_the_leader", + "breakpoints": true } ] } diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 6409dccb9c..372f639014 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -32,7 +32,8 @@ "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts", "db:backfill-subjects:prod": "op run --env-file=.env.production -- bun run scripts/backfill-subjects.ts", "db:backfill-subjects:dev": "op run --env-file=.env.op -- bun run scripts/backfill-subjects.ts", - "routes:gen": "bun scripts/gen-routes.ts" + "routes:gen": "bun scripts/gen-routes.ts", + "vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts" }, "dependencies": { "@cloudflare/vite-plugin": "^1.31.1", @@ -43,6 +44,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", @@ -53,6 +55,7 @@ "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", + "@jitl/quickjs-wasmfile-release-sync": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "~1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", @@ -74,6 +77,7 @@ "jose": "^5.6.3", "postgres": "^3.4.9", "posthog-js": "^1.372.5", + "quickjs-emscripten-core": "0.31.0", "react": "catalog:", "react-dom": "catalog:", "sonner": "^2.0.7" diff --git a/apps/cloud/scripts/vendor-quickjs-wasm.ts b/apps/cloud/scripts/vendor-quickjs-wasm.ts new file mode 100644 index 0000000000..1518babf47 --- /dev/null +++ b/apps/cloud/scripts/vendor-quickjs-wasm.ts @@ -0,0 +1,18 @@ +// Vendors the QuickJS engine WASM into src/ so wrangler's CompiledWasm module +// rule (rooted at the app dir) can statically compile it at build time. Workers +// forbid runtime WASM compilation, and the rule's glob won't reach the +// monorepo-root node_modules, so the bytes must live inside this app. +// +// Re-run after bumping @jitl/quickjs-wasmfile-release-sync: +// bun run scripts/vendor-quickjs-wasm.ts +import { copyFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const source = require.resolve("@jitl/quickjs-wasmfile-release-sync/wasm"); +const dest = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "quickjs-engine.wasm"); + +copyFileSync(source, dest); +console.log(`vendored ${source} -> ${dest}`); diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 55db864713..e95ffab268 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -226,6 +226,28 @@ export const tool_policy = pgTable( ], ); +export const artifact = pgTable( + "artifact", + { + id: varchar("id", { length: 255 }).notNull(), + title: text("title").notNull(), + description: text("description"), + code: text("code").notNull(), + bindings: json("bindings"), + preview: text("preview"), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("artifact_uidx").on(table.tenant, table.owner, table.subject, table.id)], +); + export const plugin_storage = pgTable( "plugin_storage", { diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index 748607825a..86faa45bb9 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -28,6 +28,7 @@ import * as cloudSchema from "./schema"; import * as executorSchema from "./executor-schema"; import { memberships, accounts } from "./schema"; import { + artifact, blob, connection, definition, @@ -146,6 +147,17 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { tenant, }); + await db.insert(artifact).values({ + id: `art-${tag}`, + title: "Dashboard", + code: "export default function App() { return null; }", + created_at: now, + updated_at: now, + tenant, + owner: "o", + subject: "s", + }); + const orgNs = `o:${tenant}/plugin`; const userNs = `u:${tenant}:subject/plugin`; await db.insert(blob).values({ @@ -172,6 +184,7 @@ const TENANT_TABLES = [ tool_policy, plugin_storage, subject, + artifact, ] as const; // Tables that are NOT purged by org id, each with the reason it is exempt. Any diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index 07c4b807c8..b2a922a3fd 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -16,6 +16,7 @@ import { eq, or, sql } from "drizzle-orm"; import type { DrizzleDb } from "./db"; import { organizations } from "./schema"; import { + artifact, blob, connection, definition, @@ -50,6 +51,7 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); await tx.delete(subject).where(eq(subject.tenant, organizationId)); + await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by // owner: `o:/` (org scope) and `u::/` diff --git a/apps/cloud/src/engine/execution-gate.ts b/apps/cloud/src/engine/execution-gate.ts index 6c12d53ef3..82d5f4c2ac 100644 --- a/apps/cloud/src/engine/execution-gate.ts +++ b/apps/cloud/src/engine/execution-gate.ts @@ -100,6 +100,10 @@ export const withPreExecutionGate = ( ), // resume is never gated: paused executions must be able to complete. resume: (executionId, response) => engine.resume(executionId, response), + // Optional member, so it must be forwarded explicitly — a decorator that + // rebuilds the object literal drops it, and the host then reads every settled + // execution as "never existed". + isExecutionSettled: engine.isExecutionSettled, getPausedExecution: (executionId) => engine.getPausedExecution(executionId), pausedExecutionCount: () => engine.pausedExecutionCount(), hasPausedExecutions: () => engine.hasPausedExecutions(), diff --git a/apps/cloud/src/engine/execution-usage.ts b/apps/cloud/src/engine/execution-usage.ts index 50ba3fc345..94b85cb39f 100644 --- a/apps/cloud/src/engine/execution-usage.ts +++ b/apps/cloud/src/engine/execution-usage.ts @@ -12,12 +12,15 @@ export const withExecutionUsageTracking = ( engine .execute(code, options) .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), - executeWithPause: (code) => + // `options` MUST be forwarded: it carries `autoApprove`, and dropping it + // silently turned every auto-approved execution back into a paused one. + executeWithPause: (code, options) => engine - .executeWithPause(code) + .executeWithPause(code, options) .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), // resume doesn't count as usage resume: (executionId, response) => engine.resume(executionId, response), + isExecutionSettled: engine.isExecutionSettled, getPausedExecution: (executionId) => engine.getPausedExecution(executionId), pausedExecutionCount: () => engine.pausedExecutionCount(), hasPausedExecutions: () => engine.hasPausedExecutions(), diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 33b5f88e20..7738e79c6a 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -11,6 +11,7 @@ import { } from "@executor-js/host-mcp"; import { currentPropagationHeaders, + readArtifactsEnabled, readElicitationMode, withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; @@ -131,6 +132,7 @@ const propsForPrincipal = ( organizationId: principal.organizationId, userId: principal.accountId, elicitationMode: readElicitationMode(request), + artifactsEnabled: readArtifactsEnabled(request), resource, webOrigin: new URL(request.url).origin, }, diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index c2d249aef9..075e6ad377 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -25,6 +25,9 @@ import { createExecutorMcpServer, } from "@executor-js/host-mcp/tool-server"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; +import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { McpAgentSessionDOBase, type BuiltMcpServer, @@ -64,6 +67,7 @@ import { type DbServiceShape, } from "../db/db"; import { makeExecutionStack } from "../engine/execution-stack"; +import { preloadQuickJs } from "../quickjs"; import { CloudMeteredExecutionStackLayer } from "../engine/execution-stack-metered"; import { AutumnService } from "../extensions/billing/service"; import { DoTelemetryLive, flushTracerProvider } from "../observability/telemetry"; @@ -209,6 +213,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { const self = this; return Effect.gen(function* () { + // QuickJS-WASM must be loaded before anything asks for a sandbox: the + // default variant cannot fetch its own `.wasm` on Workers. Cloud runs + // user `execute` code on the dynamic-worker runtime, but the artifact + // smoke render is a QuickJS sandbox on every host — without this it fails + // open on each create and the check silently does nothing. + // Idempotent per isolate. + yield* Effect.promise(() => preloadQuickJs()); const { executor, engine } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, @@ -250,6 +262,18 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase self.currentParentSpan(), debug: env.EXECUTOR_MCP_DEBUG === "true", browserApprovalStore: self.browserApprovalStore, diff --git a/apps/cloud/src/quickjs-engine.wasm b/apps/cloud/src/quickjs-engine.wasm new file mode 100644 index 0000000000000000000000000000000000000000..ee1a98f5a7a5c1bb10b68b7f2a9947e134bf9a58 GIT binary patch literal 518880 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
mnF(OeX)51S# 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

bfzB%jaFs)i0(O1*;r1M%vQk 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 literal 0 HcmV?d00001 diff --git a/apps/cloud/src/quickjs.ts b/apps/cloud/src/quickjs.ts new file mode 100644 index 0000000000..9d84484ed6 --- /dev/null +++ b/apps/cloud/src/quickjs.ts @@ -0,0 +1,35 @@ +import { newQuickJSWASMModuleFromVariant, newVariant } from "quickjs-emscripten-core"; +import baseVariant from "@jitl/quickjs-wasmfile-release-sync"; +// Static .wasm import: wrangler/workerd compiles this to a WebAssembly.Module at +// BUILD time. Workers forbid runtime WASM compilation (both fetching the .wasm +// and `WebAssembly.instantiate()` of bytes are blocked), so the engine bytes +// MUST be a pre-compiled module imported like this. The file is vendored into +// src/ (copied from @jitl/quickjs-wasmfile-release-sync) because wrangler's +// CompiledWasm module rule is rooted at the app dir and won't match the +// monorepo-root node_modules path — see scripts/vendor-quickjs-wasm.ts. +import wasmModule from "./quickjs-engine.wasm"; + +import { setQuickJSModule } from "@executor-js/runtime-quickjs"; + +// --------------------------------------------------------------------------- +// QuickJS-on-Workers WASM loading. +// +// The base variant's module loader resolves to the variant package's `workerd` +// build (its `./emscripten-module` export has a `workerd` condition wrangler +// selects) — that build expects the WASM module to be supplied rather than +// fetched/compiled at runtime. `newVariant(base, { wasmModule })` hands it the +// statically-imported, pre-compiled module, and `setQuickJSModule` makes every +// `makeQuickJsExecutor()` reuse it. Preloaded once per isolate. +// --------------------------------------------------------------------------- + +let preloaded: Promise | null = null; + +export const preloadQuickJs = (): Promise => { + if (!preloaded) { + const variant = newVariant(baseVariant, { wasmModule }); + preloaded = newQuickJSWASMModuleFromVariant(variant).then((mod) => { + setQuickJSModule(mod); + }); + } + return preloaded; +}; diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index e57f3de440..d139110935 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -20,12 +20,14 @@ import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as OrgRouteImport } from './routes/app/org' import { Route as BillingRouteImport } from './routes/app/billing' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' const SetupMcpRoute = SetupMcpRouteImport.update({ @@ -88,6 +90,12 @@ const BillingRoute = BillingRouteImport.update({ path: '/{-$orgSlug}/billing', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const ApiKeysRoute = ApiKeysRouteImport.update({ id: '/{-$orgSlug}/api-keys', path: '/{-$orgSlug}/api-keys', @@ -128,6 +136,15 @@ const Billing_DotplansRoute = Billing_DotplansRouteImport.update({ path: '/{-$orgSlug}/billing/plans', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport.update( { @@ -142,6 +159,7 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -150,6 +168,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute @@ -162,6 +181,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -170,6 +190,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute @@ -183,6 +204,7 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -191,6 +213,7 @@ export interface FileRoutesById { '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute @@ -205,6 +228,7 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' @@ -213,6 +237,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing/plans' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' @@ -225,6 +250,7 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' @@ -233,6 +259,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing/plans' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' @@ -245,6 +272,7 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' @@ -253,6 +281,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing_/plans' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' @@ -266,6 +295,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute SetupMcpRoute: typeof SetupMcpRoute ApiKeysRoute: typeof ApiKeysRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren BillingRoute: typeof BillingRoute OrgRoute: typeof OrgRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -360,6 +390,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BillingRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/api-keys': { id: '/{-$orgSlug}/api-keys' path: '/{-$orgSlug}/api-keys' @@ -402,6 +439,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof Billing_DotplansRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/integrations/add/$pluginKey': { id: '/{-$orgSlug}/integrations/add/$pluginKey' path: '/{-$orgSlug}/integrations/add/$pluginKey' @@ -412,6 +456,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -432,6 +491,8 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, SetupMcpRoute: SetupMcpRoute, ApiKeysRoute: ApiKeysRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, BillingRoute: BillingRoute, OrgRoute: OrgRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: @@ -459,15 +520,11 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' - import type { startInstance } from './start.ts' - declare module '@tanstack/react-start' { interface Register { ssr: true - router: Awaited> - config: Awaited> } } diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 2ae3e48cff..474eb8045c 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -21,6 +21,7 @@ import { EXECUTOR_ORG_HEADER } from "@executor-js/react/api/server-connection"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; import { AuthProvider, useAuth } from "../web/auth"; @@ -61,6 +62,13 @@ if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY) { }); } +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. A static import here would put it in this +// SSR app's server graph and 500 every document request, so it is registered as +// a dynamic import the artifact page resolves in the browser. Module scope keeps +// the loader identity stable, so the lazy component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + const analyticsClient: AnalyticsClient | undefined = typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY ? (name, properties) => posthog.capture(name, properties) @@ -327,7 +335,9 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { a bare URL gets rewritten — onto the auth org, the one thing it can mean. */} - + + + diff --git a/apps/cloud/src/wasm.d.ts b/apps/cloud/src/wasm.d.ts new file mode 100644 index 0000000000..1bfd61f61e --- /dev/null +++ b/apps/cloud/src/wasm.d.ts @@ -0,0 +1,6 @@ +// On Cloudflare Workers, a `.wasm` import resolves to a pre-compiled +// `WebAssembly.Module` (wrangler's built-in CompiledWasm module rule). +declare module "*.wasm" { + const wasmModule: WebAssembly.Module; + export default wasmModule; +} diff --git a/apps/cloud/vite.config.ts b/apps/cloud/vite.config.ts index 4cdbd7cf43..ec565da2f2 100644 --- a/apps/cloud/vite.config.ts +++ b/apps/cloud/vite.config.ts @@ -5,6 +5,7 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin, mcpAppsShellAsset } from "@executor-js/mcp-apps-shell/vite"; import { unstable_readConfig } from "wrangler"; import { routes } from "./tsr.routes"; @@ -126,6 +127,13 @@ export default defineConfig(({ command, mode }) => { plugins: [ devCrashGuard(), tailwindcss(), + // The artifact page hosts the MCP-Apps shell as a sandboxed iframe over + // the MCP-Apps protocol: `mcpAppsShellAsset` emits the built shell + // document and hands the page its URL, and `innerRendererPlugin` inlines + // the shell's own sandboxed inner frame from + // `virtual:executor-inner-renderer`. + mcpAppsShellAsset() as Plugin, + innerRendererPlugin(), executorVitePlugin(), cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }), tanstackStart({ diff --git a/apps/host-cloudflare/package.json b/apps/host-cloudflare/package.json index cab97d3677..836c44e2a1 100644 --- a/apps/host-cloudflare/package.json +++ b/apps/host-cloudflare/package.json @@ -23,6 +23,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index c2f8bb579c..5ec09fc1b3 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -9,6 +9,7 @@ import { } from "@executor-js/host-mcp"; import { currentPropagationHeaders, + readArtifactsEnabled, readElicitationMode, withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; @@ -78,6 +79,7 @@ const propsForPrincipal = ( organizationId: principal.organizationId, userId: principal.accountId, elicitationMode: readElicitationMode(request), + artifactsEnabled: readArtifactsEnabled(request), // host-cloudflare only routes the bare `/mcp` endpoint to the Agent // bridge (see worker.ts), so the session always serves the default // resource. diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 95b366b7e4..f211f52167 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -5,6 +5,9 @@ import { createExecutorMcpServer, } from "@executor-js/host-mcp/tool-server"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; +import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import type { ExecutorDbHandle } from "@executor-js/api/server"; import { McpAgentSessionDOBase, @@ -100,6 +103,7 @@ export class McpSessionDO extends McpAgentSessionDOBase preloadQuickJs()); - const { engine } = yield* makeExecutionStack( + const { engine, executor } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, sessionMeta.organizationName, @@ -124,8 +128,23 @@ export class McpSessionDO extends McpAgentSessionDOBase rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( { @@ -84,6 +92,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRou getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -102,11 +119,13 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK ) export interface FileRoutesByFullPath { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -115,11 +134,13 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -129,11 +150,13 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -144,11 +167,13 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -157,11 +182,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -170,11 +197,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -184,6 +213,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -233,6 +263,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -261,6 +298,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -278,6 +322,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -294,6 +353,8 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = ) const rootRouteChildren: RootRouteChildren = { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: diff --git a/apps/host-cloudflare/web/routes/__root.tsx b/apps/host-cloudflare/web/routes/__root.tsx index eaf4e356a9..24f8485c1d 100644 --- a/apps/host-cloudflare/web/routes/__root.tsx +++ b/apps/host-cloudflare/web/routes/__root.tsx @@ -3,6 +3,7 @@ import { useEffect, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -24,6 +25,13 @@ import { plugins as clientPlugins } from "virtual:executor/plugins-client"; // omits the API-keys nav item and just uses the default set. // --------------------------------------------------------------------------- +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. It is registered as a dynamic import the +// artifact page resolves in the browser, never a static one, so it stays out of +// any server graph. Module scope keeps the loader identity stable, so the lazy +// component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + export const Route = createRootRoute({ component: RootComponent, }); @@ -78,7 +86,13 @@ function AuthenticatedApp() { wrangler.jsonc run_worker_first), so a slug-pinned URL would fall through to the SPA. */} - {organization ? {gated} : gated} + + {organization ? ( + {gated} + ) : ( + gated + )} + diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index f0bcde52b2..f242a6cc39 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -27,6 +27,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index ff005cf623..0f2233be95 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -22,6 +22,9 @@ import { SelfHostExecutionStackLayer } from "../execution"; // identical seam with its own stack layer. // --------------------------------------------------------------------------- +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; + export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-store"; /** @@ -36,6 +39,7 @@ export const makeSelfHostMcpSessionStore = ( makeInMemoryMcpSessionStore( makeMcpBuildServer( SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), + { loadAppShellHtml: loadMcpAppsShellHtml, smokeRenderArtifact }, ), { webBaseUrl }, ); diff --git a/apps/host-selfhost/tsconfig.json b/apps/host-selfhost/tsconfig.json index e214693ed9..18cfacc0ab 100644 --- a/apps/host-selfhost/tsconfig.json +++ b/apps/host-selfhost/tsconfig.json @@ -3,6 +3,10 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", + // This host opts into the create-time artifact smoke render, whose entry + // dynamically imports a `.tsx` component graph. Nothing here renders + // anything; the compiler still resolves that graph. + "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts index 9cf280edd8..93a15cce55 100644 --- a/apps/host-selfhost/vite.config.ts +++ b/apps/host-selfhost/vite.config.ts @@ -7,6 +7,7 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin, mcpAppsShellAsset } from "@executor-js/mcp-apps-shell/vite"; import { routes } from "./tsr.routes"; import { MCP_ORIGINAL_PATH_HEADER, stripMcpOrgSegment } from "./src/mcp/org-path"; @@ -230,6 +231,12 @@ export default defineConfig({ plugins: [ executorApiPlugin(), tailwindcss(), + // The artifact page hosts the MCP-Apps shell as a sandboxed iframe over the + // MCP-Apps protocol: `mcpAppsShellAsset` emits the built shell document and + // hands the page its URL, and `innerRendererPlugin` inlines the shell's own + // sandboxed inner frame from `virtual:executor-inner-renderer`. + mcpAppsShellAsset() as Plugin, + innerRendererPlugin(), executorVitePlugin({ configPath: fileURLToPath(new URL("./executor.config.ts", import.meta.url)), }), diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index 1369bda98f..e2d2df6248 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as AdminRouteImport } from './routes/app/admin' import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' @@ -22,6 +23,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolk import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' @@ -61,6 +63,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const ApiKeysRoute = ApiKeysRouteImport.update({ id: '/{-$orgSlug}/api-keys', path: '/{-$orgSlug}/api-keys', @@ -109,6 +117,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRou getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -130,12 +147,14 @@ export interface FileRoutesByFullPath { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -147,12 +166,14 @@ export interface FileRoutesByTo { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -165,12 +186,14 @@ export interface FileRoutesById { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -184,12 +207,14 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -201,12 +226,14 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -218,12 +245,14 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -236,6 +265,7 @@ export interface RootRouteChildren { JoinDotcodeRoute: typeof JoinDotcodeRoute AdminRoute: typeof AdminRoute ApiKeysRoute: typeof ApiKeysRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -293,6 +323,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/api-keys': { id: '/{-$orgSlug}/api-keys' path: '/{-$orgSlug}/api-keys' @@ -342,6 +379,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -359,6 +403,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -378,6 +437,8 @@ const rootRouteChildren: RootRouteChildren = { JoinDotcodeRoute: JoinDotcodeRoute, AdminRoute: AdminRoute, ApiKeysRoute: ApiKeysRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index 26260c664e..96796c2a25 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -33,6 +34,13 @@ import { fetchNeedsSetup } from "../setup-status"; // Auth), injected here. No billing, Sentry, or PostHog. // --------------------------------------------------------------------------- +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. It is registered as a dynamic import the +// artifact page resolves in the browser, never a static one, so it stays out of +// any server graph. Module scope keeps the loader identity stable, so the lazy +// component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + export const Route = createRootRoute({ notFoundComponent: NotFoundPage, component: RootComponent, @@ -176,7 +184,13 @@ function AuthenticatedApp() { a slug-pinned URL would 404, and a single-org instance has nothing to select anyway. */} - {organization ? {gated} : gated} + + {organization ? ( + {gated} + ) : ( + gated + )} + diff --git a/apps/local/package.json b/apps/local/package.json index a6b2ade66b..929de78cb0 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -29,6 +29,7 @@ "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/integrations-registry": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-desktop-settings": "workspace:*", "@executor-js/plugin-example": "workspace:*", "@executor-js/plugin-file-secrets": "workspace:*", diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index 5fa7c9a0f1..2386daf351 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -91,6 +91,10 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => interface LocalExecutorBundle { readonly executor: Executor; readonly plugins: LocalPlugins; + /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced + * so callers building user-facing links (MCP artifact deep links) use the + * same origin the executor itself was configured with. */ + readonly webBaseUrl: string; } class LocalExecutorTag extends Context.Service()( @@ -233,7 +237,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { ); } - return { executor, plugins }; + return { executor, plugins, webBaseUrl }; }), ); }; @@ -246,6 +250,7 @@ export const createExecutorHandle = async (options: LocalExecutorOptions = {}) = return { executor: bundle.executor, plugins: bundle.plugins, + webBaseUrl: bundle.webBaseUrl, dispose: async () => { await Effect.runPromise(Effect.ignore(bundle.executor.close())); await ignorePromiseFailure("disposeRuntime", () => runtime.dispose()); diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index 750cd72f64..4395ef337e 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -1,6 +1,9 @@ import { Context, Data, Effect, Layer, ManagedRuntime } from "effect"; import { createExecutionEngine } from "@executor-js/execution"; +import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { makeLocalApiHandler } from "./app"; import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor"; @@ -81,15 +84,46 @@ export const createServerHandlers = async (token: string): Promise { - if (resource.kind === "default") return { config: { engine } }; + if (resource.kind === "default") { + return { + config: { + engine, + artifacts: executor.artifacts, + connections: executor.connections, + ...appsConfig, + }, + }; + } const handle = await createExecutorHandle({ activeToolkitSlug: resource.slug, }); @@ -98,7 +132,12 @@ export const createServerHandlers = async (token: string): Promise=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], + "vite-plugin-singlefile": ["vite-plugin-singlefile@2.3.3", "", { "dependencies": { "micromatch": "^4.0.8" }, "peerDependencies": { "rollup": "^4.59.0", "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg=="], + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], diff --git a/e2e/mcp-apps/.gitignore b/e2e/mcp-apps/.gitignore new file mode 100644 index 0000000000..cb5522839d --- /dev/null +++ b/e2e/mcp-apps/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +package-lock.json +test-results/ +playwright-report/ +.exdata/ diff --git a/e2e/mcp-apps/README.md b/e2e/mcp-apps/README.md new file mode 100644 index 0000000000..b93940d34f --- /dev/null +++ b/e2e/mcp-apps/README.md @@ -0,0 +1,67 @@ +# MCP Apps tests (sunpeak) + +Render and test executor's generative-UI MCP Apps (the `create-artifact` tool from the +`mcp-apps-shell` plugin) in a **real MCP-Apps host**, headlessly, in CI, with **no +VM and no Claude/ChatGPT account**. + +[sunpeak](https://github.com/Sunpeak-AI/sunpeak) locally replicates the Claude +and ChatGPT app runtimes: it connects to an MCP server, calls a UI tool, mounts +the returned `ui://` resource in a sandboxed iframe with the host bridge, and +hands the test a frame-scoped handle to the rendered component. Every test runs +against both host simulations. This is the lightweight successor to the earlier +MCPJam Playwright harness (which we drove by hand via fragile UI selectors). + +## Run + +```bash +cd e2e/mcp-apps +npm install # also patches sunpeak (see below) and is isolated from the bun workspace +npm test # builds the shell, starts sunpeak + `executor mcp`, runs the specs +``` + +`playwright.config.ts` starts `executor mcp` over **stdio** (from source, so the +shell resource is served), so there is no daemon or HTTP token to manage. Specs +live in `tests/`; `npm run test:headed` and `npm run report` help when debugging. + +A spec is tiny: + +```ts +import { test, expect } from "sunpeak/test"; +test("widget mounts", async ({ inspector }) => { + const result = await inspector.renderTool("create-artifact", { code: APP_SRC }); + const app = result.app().frameLocator("iframe"); // see "extra iframe" below + await expect(app.locator('button:has-text("Increment")')).toBeVisible(); +}); +``` + +## Two interop notes (both handled here) + +1. **sunpeak doesn't advertise the MCP-Apps UI _client_ capability.** Its + inspector connects with `new Client({ name, version })` and never declares + `capabilities.extensions["io.modelcontextprotocol/ui"]`. executor's + `create-artifact` (correctly, per the MCP-Apps spec) only mounts the widget inline + when the host advertises it can render `text/html;profile=mcp-app`; otherwise + it returns its browser **fallback URL** and nothing renders. `scripts/patch-sunpeak.mjs` + (a postinstall) adds that capability to sunpeak's client. This is + upstream-worthy: sunpeak should advertise it itself. + +2. **executor's shell nests one extra iframe.** The shell mounts the generated + component in a nested `srcdoc` iframe (`shell-app` -> `inner-renderer`, the + double-iframe sandbox), one level below sunpeak's `result.app()`. So tests use + `result.app().frameLocator("iframe")` to reach the component. + +## Why stdio + from source + +`create-artifact`'s shell resource (`ui://executor/shell-tanstack-query.html`) is +`packages/hosts/mcp-apps-shell/dist/mcp-app.html`. `pretest` builds it. Running +`executor mcp` from source serves it directly. (The compiled binary now ships +the shell too — see `apps/cli/src/build.ts` — but source is the cheaper path +for a test.) + +## Scope + +sunpeak is a host _simulation_: it covers the protocol contract, the rendered +UI, and tool/theme/display-mode behavior. It does not catch real-host quirks +(OAuth, production CSP). For the shell component in isolation there is also the +faster in-repo unit test at +`packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts`. diff --git a/e2e/mcp-apps/package.json b/e2e/mcp-apps/package.json new file mode 100644 index 0000000000..d382017772 --- /dev/null +++ b/e2e/mcp-apps/package.json @@ -0,0 +1,17 @@ +{ + "name": "@executor-js/e2e-mcp-apps", + "private": true, + "description": "Render + test executor's generative-UI MCP Apps in a real MCP-Apps host (sunpeak), headless, no VM.", + "type": "module", + "scripts": { + "postinstall": "node scripts/check-sunpeak.mjs", + "pretest": "bun run --cwd ../../packages/hosts/mcp-apps-shell build:shell", + "test": "playwright test", + "test:headed": "playwright test --headed", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.56.0", + "sunpeak": "^0.20.56" + } +} diff --git a/e2e/mcp-apps/playwright.config.ts b/e2e/mcp-apps/playwright.config.ts new file mode 100644 index 0000000000..49f264c2f8 --- /dev/null +++ b/e2e/mcp-apps/playwright.config.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { defineConfig } from "sunpeak/test/config"; + +// sunpeak runs `sunpeak inspect` (a real MCP-Apps host that mounts ui:// +// resources in a sandboxed iframe) as the Playwright web server, connects it to +// the MCP server below, and gives tests a frame-scoped handle to the rendered +// app. We point it at executor over stdio (`executor mcp`) so the whole thing is +// self-contained: no daemon to manage, no HTTP token. +// +// Run the daemon FROM SOURCE (bun apps/cli/bin/executor.ts) so the shell +// resource is served from packages/hosts/mcp-apps-shell/dist/mcp-app.html +// (`pretest` builds it). The compiled binary also ships the shell, but source +// is the cheapest path for a test. +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, "../.."); + +export default defineConfig({ + testDir: "tests", + server: { + command: "bun", + // `--artifacts` because artifacts are opt-in per MCP connection: a bare + // `executor mcp` serves no artifact tools and no ui:// shell resource, so + // there would be nothing for sunpeak to mount. + args: [resolve(repo, "apps/cli/bin/executor.ts"), "mcp", "--artifacts"], + env: { + EXECUTOR_DATA_DIR: resolve(here, ".exdata"), + }, + cwd: repo, + }, +}); diff --git a/e2e/mcp-apps/scripts/check-sunpeak.mjs b/e2e/mcp-apps/scripts/check-sunpeak.mjs new file mode 100644 index 0000000000..5b4283e1dc --- /dev/null +++ b/e2e/mcp-apps/scripts/check-sunpeak.mjs @@ -0,0 +1,45 @@ +// Verify sunpeak's inspector advertises the MCP-Apps UI *client* capability to +// the upstream MCP server. +// +// executor gates inline UI rendering on that advertisement (per the MCP-Apps +// spec: a server mounts inline only when the host says it can render +// `text/html;profile=mcp-app`). A host that doesn't advertise it gets the +// deep-link fallback instead of the widget — which would make this whole suite +// pass while testing the wrong delivery path. +// +// sunpeak used to construct its Client with no capabilities at all and we +// patched the shipped file. As of 0.20.69 it declares the capability itself, so +// this is now a CHECK rather than a patch. It stays fatal: if a future sunpeak +// drops the capability, the harness must fail loudly at install time rather +// than silently degrade. +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const target = resolve(here, "../node_modules/sunpeak/bin/commands/inspect.mjs"); + +if (!existsSync(target)) { + console.error(`[check-sunpeak] ${target} not found. Is sunpeak installed?`); + process.exit(1); +} + +const src = readFileSync(target, "utf8"); +const EXTENSION_ID = "io.modelcontextprotocol/ui"; +const MIME_TYPE = "text/html;profile=mcp-app"; + +if (!src.includes(EXTENSION_ID) || !src.includes(MIME_TYPE)) { + console.error( + [ + "[check-sunpeak] the inspector no longer advertises MCP-Apps client support.", + ` expected both ${EXTENSION_ID} and ${MIME_TYPE}`, + ` in: ${target}`, + "Without it executor returns its deep-link fallback and these tests would pass", + "while testing nothing. Pin a sunpeak version that advertises the capability,", + "or restore a patch here that injects it into the Client constructor.", + ].join("\n"), + ); + process.exit(1); +} + +console.log("[check-sunpeak] inspector advertises MCP-Apps UI client capability"); diff --git a/e2e/mcp-apps/tests/create-artifact.spec.ts b/e2e/mcp-apps/tests/create-artifact.spec.ts new file mode 100644 index 0000000000..340a7d3c3f --- /dev/null +++ b/e2e/mcp-apps/tests/create-artifact.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "sunpeak/test"; + +// executor's create-artifact shell mounts the generated component in a *nested* srcdoc +// iframe (shell-app -> inner-renderer, the MCP-Apps double-iframe sandbox), one +// level below sunpeak's own `result.app()`. Descend that extra level. +const appBody = (result: { app: () => ReturnType["app"]> } | any) => + result.app().frameLocator("iframe"); + +const COUNTER = `function App() { + const [count, setCount] = useState(0); + return ( +

+

MCP App counter

+ {count} + +
+ ); +}`; + +// sunpeak runs every test against both the Claude and ChatGPT host simulations. + +test("create-artifact mounts an interactive React widget", async ({ inspector }) => { + const result = await inspector.renderTool("create-artifact", { + code: COUNTER, + title: "MCP App counter", + }); + const app = appBody(result); + + await expect(app.locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); + const inc = app.locator('button:has-text("Increment")'); + await expect(inc).toBeVisible(); + + // state is live: clicking updates the rendered output + await inc.click(); + await expect(app.locator("text=1")).toBeVisible(); +}); + +test("create-artifact renders in dark theme", async ({ inspector }) => { + const result = await inspector.renderTool( + "create-artifact", + { code: COUNTER, title: "MCP App counter" }, + { theme: "dark" }, + ); + await expect(appBody(result).locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); +}); + +// The whole point of patching sunpeak to advertise MCP-Apps support is that +// executor returns the inline widget rather than its deep-link fallback. Assert +// that directly, so a future sunpeak change that drops the capability fails +// here instead of quietly testing the wrong delivery path. +// Protocol-level assertions go through the `mcp` fixture: `inspector` models +// what a *host* shows the user and does not surface the raw tool result. +const structuredOf = (result: { structuredContent?: unknown }): Record => + (result.structuredContent ?? {}) as Record; + +test("create-artifact returns an inline widget, not the deep-link fallback", async ({ mcp }) => { + const result = await mcp.callTool("create-artifact", { + code: COUNTER, + title: "MCP App counter", + description: "A counter used by the MCP Apps e2e harness", + }); + const structured = structuredOf(result); + // The whole point of the inspector advertising MCP-Apps support: executor + // must hand back the component, not a URL to go look at it elsewhere. + expect(structured.status).not.toBe("fallback_url"); + expect(structured.code).toContain("MCP App counter"); + // Every successful render is persisted, so the artifact can be reopened. + expect(structured.artifactId).toBeTruthy(); +}); + +test("a rendered artifact is listed and can be shown again", async ({ inspector, mcp }) => { + const rendered = await mcp.callTool("create-artifact", { + code: COUNTER, + title: "Retrievable counter", + description: "Saved so list-artifacts can find it", + }); + const artifactId = structuredOf(rendered).artifactId as string; + expect(artifactId).toBeTruthy(); + + const listed = await mcp.callTool("list-artifacts", {}); + const artifacts = structuredOf(listed).artifacts as Array<{ id: string }> | undefined; + expect(artifacts?.some((artifact) => artifact.id === artifactId)).toBe(true); + + // …and the stored source really does render, through the same shell. + const shown = await inspector.renderTool("show-artifact", { id: artifactId }); + await expect(appBody(shown).locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); +}); diff --git a/e2e/scenarios/artifact-approval.test.ts b/e2e/scenarios/artifact-approval.test.ts new file mode 100644 index 0000000000..d2d4ef937b --- /dev/null +++ b/e2e/scenarios/artifact-approval.test.ts @@ -0,0 +1,229 @@ +// Cross-target: approving a destructive action triggered from inside an +// artifact. +// +// This is the console's own path, not MCP. The artifact page has no MCP client, +// so the shell's host adapter posts `POST /executions` (with the artifact id) +// and then `POST /executions/:id/resume` when the human answers the modal. The +// existing approval coverage all runs over MCP, where the engine lives for the +// lifetime of a session Durable Object — so none of it could see that on a host +// building an executor per request the pause and the resume land on DIFFERENT +// engine instances, and the approval failed with `ExecutionNotFoundError`. +// +// Two things are asserted that the older scenarios could not: +// +// 1. The approval is honoured at all on this path, and the side effect lands. +// 2. It is still honoured after a human-scale delay. The reported symptom was +// an approval that took "tens of seconds", so a scenario approving +// instantly would stay green against the broken code. It is worth being +// precise about why: the bug is NOT time-based (a resume 7ms after the +// pause failed identically), so the delay is not what makes this catch the +// regression — the artifact-scoped path is. The delay guards the separate +// promise that an approval window is human-scale. +import { randomUUID } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const coreApi = composePluginApi([] as const); + +/** + * How long the scenario waits before approving. + * + * Long enough to be a real human pause rather than a same-tick round trip, and + * well past the 4-minute paused-execution lease the MCP plane advertises being + * irrelevant here. Kept modest so the scenario stays inside the default timeout. + */ +const APPROVAL_DELAY_MS = 65_000; + +/** + * The one call the shell's proxy is allowed to emit: a single awaited tool call, + * no statements around it. `policies.create` carries a `requiresApproval` + * annotation, so with no matching policy in play the annotation is the only + * thing that can pause it — and the pattern is unique-per-run and matches no + * real tool, so the created rule is inert. + * + * `executor` is a reserved tool root (`RESERVED_TOOL_ROOTS`), so it resolves + * without a connection binding — which lets the scenario exercise the + * artifact-scoped approval path without first connecting an integration. + */ +const artifactActionCode = (pattern: string) => + `return await tools.executor.coreTools.policies.create(${JSON.stringify({ + owner: "user", + pattern, + action: "block", + })})`; + +/** A minimal artifact. Its source is irrelevant to the approval path — what + * matters is that a real artifact id scopes the call, exactly as the rendered + * page supplies it. */ +const artifactSource = ` +function App() { + return
Approval fixture
; +} +`; + +const pausedExecutionId = (structured: unknown): string | undefined => + (structured as { readonly executionId?: string } | null)?.executionId; + +scenario( + "Artifacts · a destructive action approved from an artifact runs, even minutes later", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const apiSurface = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiSurface.client(coreApi, identity); + + const pattern = `artifact-approval-${randomUUID().slice(0, 8)}.*`; + const code = artifactActionCode(pattern); + + const artifact = yield* client.artifacts.save({ + payload: { + title: `Approval fixture ${randomUUID().slice(0, 8)}`, + code: artifactSource, + }, + }); + + const cleanup = Effect.all( + [ + client.artifacts + .remove({ params: { artifactId: artifact.id as ArtifactId } }) + .pipe(Effect.ignore), + client.policies.list().pipe( + Effect.flatMap((list) => + Effect.forEach( + list.filter((p) => p.pattern === pattern), + (p) => + client.policies + .remove({ params: { policyId: p.id }, payload: { owner: "user" } }) + .pipe(Effect.ignore), + ), + ), + Effect.ignore, + ), + ], + { discard: true }, + ); + + yield* Effect.gen(function* () { + // The artifact fires its mutation. The tool gates itself, so the shell + // gets a pause back and renders the approval modal. + const paused = yield* client.executions.execute({ + payload: { code, artifactId: artifact.id }, + }); + expect(paused.status, "a destructive artifact action pauses for approval").toBe("paused"); + if (paused.status !== "paused") return; // narrowing only + + const executionId = pausedExecutionId(paused.structured); + expect(executionId, "the pause carries an execution id to approve").toBeTruthy(); + if (!executionId) return; // narrowing only + + // Nothing may happen while the human is still deciding. + const beforeApproval = yield* client.policies.list(); + expect( + beforeApproval.some((p) => p.pattern === pattern), + "the action does not run while it is waiting on approval", + ).toBe(false); + + // The human reads the request and decides. This is the wait that makes the + // approval window a real promise rather than a same-request formality. + yield* Effect.sleep(APPROVAL_DELAY_MS); + + const approved = yield* client.executions.resume({ + params: { executionId }, + payload: { action: "accept", content: {} }, + }); + + expect(approved.status, "approving the action runs it to completion").toBe("completed"); + if (approved.status !== "completed") return; // narrowing only + expect(approved.isError, "the approved action is not an error").toBe(false); + + const afterApproval = yield* client.policies.list(); + expect( + afterApproval.some((p) => p.pattern === pattern), + "the approved action's side effect actually landed", + ).toBe(true); + }).pipe(Effect.ensuring(cleanup)); + }), +); + +scenario( + "Artifacts · declining an artifact action leaves it unrun", + {}, + Effect.gen(function* () { + const target = yield* Target; + const apiSurface = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiSurface.client(coreApi, identity); + + const pattern = `artifact-decline-${randomUUID().slice(0, 8)}.*`; + const code = artifactActionCode(pattern); + + const artifact = yield* client.artifacts.save({ + payload: { + title: `Decline fixture ${randomUUID().slice(0, 8)}`, + code: artifactSource, + }, + }); + + const cleanup = Effect.all( + [ + client.artifacts + .remove({ params: { artifactId: artifact.id as ArtifactId } }) + .pipe(Effect.ignore), + client.policies.list().pipe( + Effect.flatMap((list) => + Effect.forEach( + list.filter((p) => p.pattern === pattern), + (p) => + client.policies + .remove({ params: { policyId: p.id }, payload: { owner: "user" } }) + .pipe(Effect.ignore), + ), + ), + Effect.ignore, + ), + ], + { discard: true }, + ); + + yield* Effect.gen(function* () { + const paused = yield* client.executions.execute({ + payload: { code, artifactId: artifact.id }, + }); + expect(paused.status, "the action pauses for approval").toBe("paused"); + if (paused.status !== "paused") return; // narrowing only + + const executionId = pausedExecutionId(paused.structured); + if (!executionId) return; // narrowing only + + yield* client.executions.resume({ + params: { executionId }, + payload: { action: "decline" }, + }); + + const afterDecline = yield* client.policies.list(); + expect( + afterDecline.some((p) => p.pattern === pattern), + "a declined action never runs", + ).toBe(false); + + // A declined approval is spent: the same id cannot be re-approved into a + // run afterwards. + yield* client.executions + .resume({ params: { executionId }, payload: { action: "accept", content: {} } }) + .pipe(Effect.ignore); + + const afterRetry = yield* client.policies.list(); + expect( + afterRetry.some((p) => p.pattern === pattern), + "approving after a decline does not resurrect the action", + ).toBe(false); + }).pipe(Effect.ensuring(cleanup)); + }), +); diff --git a/e2e/scenarios/artifact-loading-surface.test.ts b/e2e/scenarios/artifact-loading-surface.test.ts new file mode 100644 index 0000000000..33753fc0cd --- /dev/null +++ b/e2e/scenarios/artifact-loading-surface.test.ts @@ -0,0 +1,330 @@ +// Opening an artifact shows ONE loading surface, not three. +// +// ## What went wrong +// +// Opening an artifact used to walk through three visually distinct placeholders +// in quick succession, each in its own layout and each visible for a few hundred +// milliseconds: +// +// 1. "Loading artifact…" — the row being fetched, small corner text +// 2. "Preparing the renderer…" — the lazy chunk arriving, a different corner +// 3. "Preparing interactive UI" — the shell booting, a card in the middle +// +// Those are executor's own internal boundaries — an HTTP request, a code-split +// point, an iframe handshake — and none of them is a fact a person opening a +// saved dashboard has any use for. What they produced was churn: three +// different things flashing in three different places. Three quick loading +// states are worse than one longer one, because every transition is a fresh +// demand on the eye. +// +// ## What is asserted, and why it is asserted this way +// +// The honest test of "the user never saw a placeholder" is not a screenshot at +// one moment — the states this is about were only ever visible for a few +// hundred milliseconds, so any single sample would miss them and pass against +// the old code too. So the page is SAMPLED CONTINUOUSLY, from before navigation +// until the artifact is live, and the assertion is over everything that was ever +// on screen: +// +// - none of the three placeholder texts ever appeared; +// - the stage's bounding box never moved, so nothing was ever laid out twice. +// +// Both directions are covered, because they fail differently: warm (from the +// gallery, renderer chunk already fetched) and cold (a direct URL in a fresh +// context, nothing cached). +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; +import type { Page } from "playwright"; +import { composePluginApi } from "@executor-js/api/server"; +import { AccountHttpApi } from "@executor-js/api"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; + +const api = composePluginApi([] as const); + +const uniqueSuffix = () => randomBytes(4).toString("hex"); + +/** + * The placeholder texts that must never reach a user again. + * + * Named literally rather than by testid: the point is that the WORDS are gone + * from the experience, and a rename that kept the churn under a different + * caption should not pass. Each string is the exact one the corresponding + * placeholder rendered before this change. + */ +const FORBIDDEN_LOADING_TEXT = [ + "Loading artifact", + "Preparing the renderer", + "Preparing interactive UI", +] as const; + +/** A tall, distinctive artifact — the same shape the main artifacts scenario + * uses, so the fill-mode sizing under test here is the real one. */ +const artifactSource = (marker: string) => ` +function App() { + return ( +
+
+

Loading Surface

+

${marker}

+
+
+ {Array.from({ length: 30 }, (_, i) => ( +

+ Row {i + 1}: ${marker} +

+ ))} +
+
+ ); +} +`; + +const structuredOf = (result: { readonly raw: unknown }): Record => + ((result.raw as { structuredContent?: Record }).structuredContent ?? + {}) as Record; + +const artifactContent = (page: Page) => + page.frameLocator('[data-testid="artifact-shell-frame"]').frameLocator("iframe"); + +type Sample = { + readonly text: string; + readonly stage: { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; + } | null; +}; + +declare global { + // eslint-disable-next-line no-var + var __loadingSamples: Array | undefined; + // eslint-disable-next-line no-var + var __loadingSampler: number | undefined; +} + +/** + * Watch the CONSOLE document continuously for the whole open. + * + * Installed as an init script so it is running before the first byte of the + * page, and polls on an animation frame — fast enough that a placeholder + * visible for even one paint is recorded. `innerText` of the console document + * only (never the frames): the shell's own inner states live one document down + * and are covered separately, and reaching into a frame from here would sample + * the artifact's own text as if it were the console's. + * + * The stage's box is sampled alongside, because a placeholder that came and + * went without changing any TEXT would still have moved the layout, and a + * layout that jumps is the same defect wearing a different hat. + */ +const startSampling = async (page: Page): Promise => { + await page.addInitScript(() => { + globalThis.__loadingSamples = []; + const sample = () => { + const stage = document.querySelector( + '[data-testid="artifact-shell-container"], [data-slot="artifact-stage-skeleton"]', + ); + const box = stage?.getBoundingClientRect(); + globalThis.__loadingSamples?.push({ + text: document.body?.innerText ?? "", + stage: box + ? { + x: Math.round(box.x), + y: Math.round(box.y), + w: Math.round(box.width), + h: Math.round(box.height), + } + : null, + }); + globalThis.__loadingSampler = requestAnimationFrame(sample); + }; + sample(); + }); +}; + +const readSamples = (page: Page): Promise> => + page.evaluate(() => globalThis.__loadingSamples ?? []); + +/** + * Assert the whole open was one surface. + * + * Two independent properties over the same recording — no placeholder words + * ever rendered, and the stage never changed size or position — because either + * one alone would let the churn back in through the other door. + */ +const expectSingleLoadingSurface = (samples: ReadonlyArray, label: string): void => { + expect(samples.length, `${label}: the sampler actually ran`).toBeGreaterThan(3); + + for (const forbidden of FORBIDDEN_LOADING_TEXT) { + const hit = samples.findIndex((entry) => entry.text.includes(forbidden)); + expect( + hit, + `${label}: "${forbidden}" was on screen at sample ${hit} of ${samples.length} — the open still walks through more than one loading state`, + ).toBe(-1); + } + + // The stage's geometry, over every frame in which a stage existed at all. + // The skeleton and the artifact frame share one box by construction (the + // skeleton is an absolutely-positioned cover over the frame's own container), + // so a change here means one of them was laid out differently from the other. + const boxes = samples.map((entry) => entry.stage).filter(Predicate.isNotNull); + expect(boxes.length, `${label}: the stage was on screen at some point`).toBeGreaterThan(0); + + const first = boxes[0]; + if (!first) return; + for (const [index, box] of boxes.entries()) { + // A pixel of tolerance for sub-pixel rounding as the scrollbar settles. + expect( + Math.abs(box.w - first.w) <= 1 && Math.abs(box.h - first.h) <= 1, + `${label}: the stage changed size mid-load at sample ${index} (${JSON.stringify(box)} vs ${JSON.stringify(first)}) — the artifact appeared in a different box than the skeleton held`, + ).toBe(true); + } +}; + +scenario( + "Artifacts · opening an artifact shows one loading surface, not three", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + const { client: apiClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* apiClient(api, identity); + // Artifacts are opt-in per MCP connection, so this session asks for the + // surface it is here to exercise (`?artifacts=true`). + const session = mcp.session(identity, { artifacts: true }); + + const suffix = uniqueSuffix(); + const title = `Loading Surface ${suffix}`; + const marker = `surface-ok-${suffix}`; + + let artifactId: ArtifactId | undefined; + + yield* Effect.gen(function* () { + const created = yield* session.call("create-artifact", { + code: artifactSource(marker), + title, + description: "An artifact opened to watch how it loads", + }); + expect(created.ok, `create-artifact succeeded: ${created.text}`).toBe(true); + artifactId = structuredOf(created).artifactId as ArtifactId; + expect(artifactId, "the artifact was persisted").toBeTruthy(); + + const accountClient = yield* apiClient(AccountHttpApi, identity); + const me = yield* accountClient.account.me(); + const orgSlug = me.organization?.slug; + const detailPath = orgSlug + ? `/${orgSlug}/artifacts/${artifactId}` + : `/artifacts/${artifactId}`; + + // ------------------------------------------------------------------ + // WARM: the journey a user actually takes — gallery, then a click. + // ------------------------------------------------------------------ + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Artifacts gallery", async () => { + await startSampling(page); + await page.goto(`${target.baseUrl}/artifacts`, { waitUntil: "networkidle" }); + await page + .getByRole("link", { name: `Open artifact ${title}` }) + .waitFor({ timeout: 30_000 }); + // Discard everything from the gallery's own load: this scenario is + // about the OPEN, and the list has a loading state of its own that is + // out of scope here. + await page.evaluate(() => { + globalThis.__loadingSamples = []; + }); + }); + + await step("Click through to the artifact", async () => { + await page.getByRole("link", { name: `Open artifact ${title}` }).click(); + await artifactContent(page).getByTestId("artifact-marker").waitFor({ timeout: 60_000 }); + }); + + await step("The whole open was one surface", async () => { + expectSingleLoadingSurface(await readSamples(page), "warm open"); + }); + + await step("The skeleton hands over and gets out of the way", async () => { + // The cover is removed once the artifact has painted — not left over + // it at zero opacity, which would swallow every click. + await expect + .poll(async () => await page.locator('[data-testid="artifact-stage-cover"]').count(), { + timeout: 20_000, + message: "the loading cover was removed after the artifact rendered", + }) + .toBe(0); + }); + + await step("Fill-mode sizing survived the handoff", async () => { + // The frame is the viewport below the title bar, exactly as it is in + // the steady state — so nothing about holding a skeleton over it + // changed the height chain it measures against. + const frameBox = await page.locator('[data-testid="artifact-shell-frame"]').boundingBox(); + const viewport = page.viewportSize(); + expect(frameBox?.height ?? 0, "the frame fills the room it was given").toBeGreaterThan( + 200, + ); + expect( + frameBox?.height ?? 0, + "the frame is bounded by the window, not by its content", + ).toBeLessThanOrEqual(viewport?.height ?? 0); + + const pageScrolls = await page.evaluate( + () => document.documentElement.scrollHeight > document.documentElement.clientHeight + 1, + ); + expect(pageScrolls, "the console page still does not scroll").toBe(false); + }); + }); + + // ------------------------------------------------------------------ + // COLD: the deep link, in a context that has never loaded the console. + // + // The harder case, and the one the preload exists for: nothing is cached, + // so the row fetch and the renderer chunk both start from zero. This is + // where the three states used to be most visible, and where a regression + // in the preload would show up first. + // ------------------------------------------------------------------ + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the artifact by URL, cold", async () => { + await startSampling(page); + await page.goto(`${target.baseUrl}${detailPath}`, { waitUntil: "commit" }); + await artifactContent(page).getByTestId("artifact-marker").waitFor({ timeout: 60_000 }); + }); + + await step("The cold open was one surface too", async () => { + expectSingleLoadingSurface(await readSamples(page), "cold open"); + }); + + await step("The artifact is live and interactive", async () => { + const rendered = artifactContent(page).getByTestId("artifact-marker"); + expect(await rendered.textContent()).toContain(marker); + + // Interactive, not merely painted: the cover is gone, so a scroll + // inside the artifact actually reaches it. + const scrolled = await artifactContent(page) + .getByTestId("artifact-scroll") + .evaluate((el) => { + el.scrollTop = 200; + return el.scrollTop; + }); + expect(scrolled, "the artifact takes input after the handoff").toBeGreaterThan(0); + }); + }); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + artifactId === undefined + ? Effect.void + : client.artifacts.remove({ params: { artifactId } }), + ).pipe(Effect.ignore), + ), + ); + }), +); diff --git a/e2e/scenarios/artifact-preview-gallery.test.ts b/e2e/scenarios/artifact-preview-gallery.test.ts new file mode 100644 index 0000000000..fe7dabe50e --- /dev/null +++ b/e2e/scenarios/artifact-preview-gallery.test.ts @@ -0,0 +1,281 @@ +// A visual check of the artifact gallery's previews, in light and dark. +// +// The other artifacts scenarios prove the mechanism: the layout preview is +// stored at create time and upgraded to a snapshot once the artifact is opened. +// This one exists to show what that actually LOOKS like across the shapes a +// model really produces — a stat grid, a chart, and a component whose data is +// still loading — because "the preview is real" is a claim about appearance and +// the only honest way to check appearance is to look at it. +// +// It seeds through `create-artifact` rather than the storage API, so every +// preview here was produced by the same smoke render a model's create runs +// through. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Mcp, Target } from "../src/services"; + +const uniqueSuffix = () => randomBytes(3).toString("hex"); + +/** A stat grid over a table: the most common dashboard shape. */ +const dashboardSource = ` +function App() { + const metrics = [ + { label: "Active users", value: "12,480", delta: "+8.2% vs last week" }, + { label: "Sessions", value: "48,201", delta: "+3.1% vs last week" }, + { label: "Error rate", value: "0.42%", delta: "-0.3% vs last week" }, + { label: "p95 latency", value: "284ms", delta: "+12ms vs last week" }, + ]; + const rows = Array.from({ length: 6 }, (_, i) => ({ + name: "checkout-service-" + (i + 1), + status: i % 3 === 0 ? "degraded" : "healthy", + })); + return ( +
+
+

Release Readiness

+

Rolling 24h across all services

+
+
+ {metrics.map((m) => ( + + + {m.label} + {m.value} + + + {m.delta} + + + ))} +
+ + Services + + + + + Service + Status + + + + {rows.map((r) => ( + + {r.name} + + + {r.status} + + + + ))} + +
+
+
+
+ ); +}`; + +/** A chart: the shape whose preview is almost entirely SVG geometry. */ +const chartSource = ` +function App() { + const data = [ + { month: "Jan", revenue: 4200 }, { month: "Feb", revenue: 5100 }, + { month: "Mar", revenue: 4800 }, { month: "Apr", revenue: 6300 }, + { month: "May", revenue: 7100 }, { month: "Jun", revenue: 8250 }, + ]; + return ( +
+
+

Revenue by month

+

Current fiscal year

+
+ + + + + + + + + + + + +
+ ); +}`; + +/** + * A component showing its loading state: the shape whose preview is a skeleton. + * + * Deliberately does NOT call a tool. A real `tools.*` query would need a bound + * connection, and binding one here would test the binding path rather than the + * preview — while the SKELETON, which is what this case is about, is identical + * either way: the smoke render's transport never settles, so every querying + * artifact previews exactly this. + */ +const loadingSource = ` +function App() { + return ( +
+
+

Open Issues

+

Loading from the tracker

+
+ +
+ ); +}`; + +scenario( + "Artifacts · the gallery shows each artifact's real layout, in light and dark", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + + const identity = yield* target.newIdentity(); + // Artifacts are opt-in per MCP connection, so this session asks for the + // surface it is here to exercise (`?artifacts=true`). + const session = mcp.session(identity, { artifacts: true }); + const suffix = uniqueSuffix(); + + const seeded = [ + ["Release Readiness", dashboardSource, "Service health across the fleet"], + ["Revenue by Month", chartSource, "Monthly revenue for the fiscal year"], + ["Open Issues", loadingSource, "Open GitHub issues, live"], + ] as const; + + for (const [name, code, description] of seeded) { + const result = yield* session.call("create-artifact", { + code, + title: `${name} ${suffix}`, + description, + }); + expect(result.ok, `${name} saved: ${result.text}`).toBe(true); + } + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Artifacts gallery", async () => { + await page.goto(`${target.baseUrl}/artifacts`, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); + // Every seeded artifact is present before anything is measured. + for (const [name] of seeded) { + await page + .getByRole("link", { name: `Open artifact ${name} ${suffix}` }) + .waitFor({ timeout: 30_000 }); + } + }); + + await step("Every card draws the artifact's own layout", async () => { + const previews = page.locator('[data-slot="artifact-preview"]'); + await expect + .poll(async () => await previews.count(), { timeout: 20_000 }) + .toBe(seeded.length); + + // Not one schematic among them: all three previews came from a real + // render of the artifact's own source. + for (let index = 0; index < seeded.length; index += 1) { + expect( + await previews.nth(index).getAttribute("data-preview-kind"), + `card ${index} shows a captured preview rather than the fallback`, + ).toBe("layout"); + } + + // And the content is the artifact's, not a generic frame: each card + // carries text or geometry its own source produced. + const gallery = await page.locator('[data-slot="artifact-grid"]').innerText(); + expect(gallery, "the dashboard's own heading is on its card").toContain( + "Release Readiness", + ); + expect(gallery, "the dashboard's real stat labels are on its card").toContain( + "Active users", + ); + expect(gallery, "the chart artifact's heading is on its card").toContain( + "Revenue by month", + ); + + // A chart's LAYOUT preview is its frame and heading, not its bars. + // + // That is a property of Recharts, not of the sanitizer: its + // `ResponsiveContainer` measures the element it is inside, and on a + // server there is nothing to measure, so it renders a sized wrapper and + // no SVG at all. Asserting bars here would be asserting something no + // server render can produce. What the card can promise is the artifact's + // identity and its frame — and the second layer, the snapshot taken once + // the artifact has actually been opened in a browser, is what fills in + // the geometry. See the artifacts scenario for that half. + const chartCard = page + .locator('[data-slot="artifact-card"]') + .filter({ hasText: `Revenue by Month ${suffix}` }); + const chartPreview = chartCard.locator('[data-slot="artifact-preview"]'); + expect( + await chartPreview.getAttribute("data-preview-kind"), + "the chart artifact still gets a real layout preview", + ).toBe("layout"); + expect( + await chartPreview.locator(".recharts-responsive-container").count(), + "the chart's frame is drawn even though its geometry needs a viewport", + ).toBeGreaterThan(0); + + // The preview's STRUCTURE has to survive, not just its text. The card + // renders the markup in the console document, so a layout class the + // model used that this stylesheet never compiled would silently + // collapse a four-up stat row into one column — the preview would still + // "work" and would be showing the wrong shape. `grid-cols-4` is the + // case: the console itself never needs one, so only the safelist in + // `globals.css` puts it in the stylesheet. + const statColumns = await page + .locator('[data-slot="artifact-card"]') + .filter({ hasText: `Release Readiness ${suffix}` }) + .locator('[data-slot="artifact-preview"] .grid-cols-4') + .first() + .evaluate( + (node) => + globalThis + .getComputedStyle(node) + .gridTemplateColumns.split(" ") + .filter((track) => track.length > 0).length, + ); + expect( + statColumns, + "the dashboard's four-column stat row is still four columns in the preview", + ).toBe(4); + }); + + await step("Gallery in dark mode", async () => { + // The stored markup is token-based, so the SAME markup has to read + // correctly against the console's dark surface without being restyled. + await page.emulateMedia({ colorScheme: "dark" }); + await page.reload({ waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); + await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); + + // The card's own background follows the theme, and the preview inside + // it inherits the console's tokens rather than carrying its own. + const card = page.locator('[data-slot="artifact-card"]').first(); + const surface = await card.evaluate( + (node) => globalThis.getComputedStyle(node).backgroundColor, + ); + expect(surface, "the card is drawn on a dark surface").not.toBe("rgb(255, 255, 255)"); + }); + + await step("Gallery in light mode", async () => { + await page.emulateMedia({ colorScheme: "light" }); + await page.reload({ waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); + await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); + }); + }); + }), +); diff --git a/e2e/scenarios/artifacts.test.ts b/e2e/scenarios/artifacts.test.ts new file mode 100644 index 0000000000..212135a1e3 --- /dev/null +++ b/e2e/scenarios/artifacts.test.ts @@ -0,0 +1,830 @@ +// Cross-target: the artifacts journey, end to end. +// +// An agent on a client that cannot display MCP Apps calls `create-artifact`. The +// product promise under test is vision.md's delivery negotiation: the model +// behaves identically either way, and only DELIVERY changes — a client without +// MCP Apps gets a deep link into the web app instead of an embedded widget. +// Persistence is what makes that possible, so the same artifact must then be +// reachable three ways: the deep link, the Artifacts tab, and back through MCP +// by title. +// +// Two scenarios, split by what they prove: +// 1. create-artifact saves + delivers a working deep link, and the page renders +// the component live (the fallback path a non-Apps client actually walks). +// 2. Rename and delete in the console are what MCP reads back afterwards +// (the console and the agent share one store, not two caches). +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { Page } from "playwright"; +import { composePluginApi } from "@executor-js/api/server"; +import { AccountHttpApi } from "@executor-js/api"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; + +const api = composePluginApi([] as const); + +/** + * The component `create-artifact` persists. + * + * It must declare none of the ~280 globals the shell puts in scope (`Card`, + * `useState`, …) or the server's guard rejects it before it is ever saved, and + * its rendered text must be distinctive enough to assert on inside the shell's + * nested sandbox iframe. + * + * It is purely declarative, which is now the whole contract rather than a + * stylistic choice: there is no `run()` in scope, and code that calls one is + * rejected before it is saved. Rows come from `Array.from`, a display constant — + * an artifact that needed live data would reach for + * `useQuery(tools...queryOptions(...))`. + * + * It is also deliberately TALL — 40 rows at 28px, far more than fits on the + * detail page — and laid out the way the `artifact-style` skill teaches: a + * bounded flex column whose header stays put while one region scrolls. That is + * what the detail page's fill mode exists to support, and what the steps below + * assert: the frame is the viewport, the page itself does not scroll, and the + * heading is still on screen after the rows have been scrolled. + */ +const ARTIFACT_ROW_COUNT = 40; + +const artifactSource = (marker: string) => ` +function App() { + return ( +
+
+

Release Readiness

+

${marker}

+
+
+ {Array.from({ length: ${ARTIFACT_ROW_COUNT} }, (_, i) => ( +

+ Check {i + 1}: ${marker} +

+ ))} +
+
+ ); +} +`; + +/** Selfhost shares one workspace across scenarios, so every title is unique to + * this run and assertions look for "mine", never "the only one". */ +const uniqueSuffix = () => randomBytes(4).toString("hex"); + +const structuredOf = (result: { readonly raw: unknown }): Record => + ((result.raw as { structuredContent?: Record }).structuredContent ?? + {}) as Record; + +/** + * The generated component, two frames down. + * + * The artifact page hosts the shell DOCUMENT in a sandboxed iframe and speaks + * the MCP-Apps protocol to it — the same way any MCP-Apps client loads + * `ui://executor/shell.html`. The shell then compiles the stored JSX into its + * own nested sandbox. Both hops are load-bearing, so tests descend through both. + */ +const artifactContent = (page: Page) => + page.frameLocator('[data-testid="artifact-shell-frame"]').frameLocator("iframe"); + +/** + * A fingerprint of the CONSOLE document's styling. + * + * The shell ships its own Tailwind build and its own palette; the console's is + * strictly grayscale. Sampling a token, a real rendered control, and the + * stylesheet count catches the leak whether it arrives as a `ok
", + ); + expect(result).toBe("
ok
"); + expect(result).not.toContain("evil.test"); + }); + + it("does not let a nested tag close the skip early", () => { + const result = sanitizeArtifactPreviewMarkup( + "
after
", + ); + expect(result).toBe("
after
"); + expect(result).not.toContain("still inside"); + expect(result).not.toContain("payload"); + }); + + it("removes noscript, template and object with their contents", () => { + for (const tag of ["noscript", "template", "object"]) { + const result = sanitizeArtifactPreviewMarkup(`
<${tag}>hiddenkept
`); + expect(result).toBe("
kept
"); + } + }); + }); + + describe("event handlers", () => { + it("strips every on* attribute", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("steal"); + }); + + it("strips handlers whatever their casing", () => { + const result = sanitizeArtifactPreviewMarkup('
x
'); + expect(result).not.toContain("steal"); + }); + + it("strips a handler whose value contains a closing angle bracket", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("steal"); + }); + }); + + describe("external references", () => { + it("drops img entirely, so no request is ever made", () => { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + expect(result).not.toContain("evil.test"); + }); + + it("drops iframe and its content", () => { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + }); + + it("unwraps an anchor but keeps its text, dropping the href", () => { + const result = sanitizeArtifactPreviewMarkup( + '
', + ); + expect(result).toBe("
Dashboard
"); + expect(result).not.toContain("evil.test"); + }); + + it("drops a style declaration that can fetch", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("evil.test"); + }); + + it("drops a url() smuggled into an allowed style property", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("evil.test"); + }); + + it("drops position and transform, which would escape the card", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + }); + + it("drops an svg url() paint that points anywhere but a local id", () => { + const result = sanitizeArtifactPreviewMarkup( + '', + ); + expect(result).toContain(" { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + }); + + it("neutralises a javascript: scheme wherever it lands", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).not.toContain("javascript:"); + }); + }); + + describe("interactive elements", () => { + it("unwraps a button, keeping its label", () => { + const result = sanitizeArtifactPreviewMarkup("
"); + expect(result).toBe("
Refresh
"); + }); + + it("drops form controls", () => { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + expect(result).not.toContain("evil.test"); + }); + }); + + describe("ids", () => { + it("namespaces ids so a preview cannot collide with the host document", () => { + const result = sanitizeArtifactPreviewMarkup('
x
'); + expect(result).toBe('
x
'); + }); + + it("rewrites local paint references in step with the ids", () => { + const result = sanitizeArtifactPreviewMarkup( + '' + + '', + ); + expect(result).toContain('id="artifact-preview-grad"'); + expect(result).toContain('fill="url(#artifact-preview-grad)"'); + }); + }); + + describe("text and entities", () => { + it("keeps escaped markup escaped, and does not let it re-enter as an element", () => { + const result = sanitizeArtifactPreviewMarkup( + "
<script>alert(1)</script>
", + ); + expect(result).toBe("
<script>alert(1)</script>
"); + expect(result).not.toContain(" { + // The input is already-escaped HTML. Escaping `&` a second time would + // turn `&` into `&amp;` and corrupt every Tailwind arbitrary + // variant in the markup. + const once = sanitizeArtifactPreviewMarkup("
a < b && c
"); + expect(once).toBe("
a < b && c
"); + expect(sanitizeArtifactPreviewMarkup(once ?? "")).toBe(once); + }); + + it("preserves a Tailwind arbitrary variant containing an ampersand", () => { + // What React writes for `[&_.recharts-layer]:outline-hidden`. + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("&amp;"); + }); + }); + + describe("size", () => { + it("returns null past the cap", () => { + const huge = `
${"x".repeat(ARTIFACT_PREVIEW_MARKUP_LIMIT + 1)}
`; + expect(sanitizeArtifactPreviewMarkup(huge)).toBeNull(); + }); + + it("keeps markup just under the cap", () => { + const body = "x".repeat(1000); + expect(sanitizeArtifactPreviewMarkup(`
${body}
`)).toBe(`
${body}
`); + }); + }); + + describe("nothing usable", () => { + it("returns null for empty input", () => { + expect(sanitizeArtifactPreviewMarkup("")).toBeNull(); + }); + + it("returns null when everything sanitized away", () => { + expect(sanitizeArtifactPreviewMarkup("")).toBeNull(); + }); + + it("returns null for whitespace-only structure", () => { + expect(sanitizeArtifactPreviewMarkup("
")).toBeNull(); + }); + + it("keeps a purely geometric render that has no text at all", () => { + // A chart-only artifact is a real preview even with zero text nodes. + const result = sanitizeArtifactPreviewMarkup( + '
', + ); + expect(result).not.toBeNull(); + expect(result).toContain(" { + it("does not hang or throw on an unterminated tag", () => { + expect(() => sanitizeArtifactPreviewMarkup('
{ + expect(() => sanitizeArtifactPreviewMarkup("
", next + 4); + index = end === -1 ? markup.length : end + 3; + continue; + } + if (markup.startsWith("", next); + index = end === -1 ? markup.length : end + 1; + continue; + } + + // Walk to this tag's `>`, skipping any inside a quoted attribute value. + let cursor = next + 1; + let quote = ""; + while (cursor < markup.length) { + const char = markup[cursor]; + if (quote !== "") { + if (char === quote) quote = ""; + } else if (char === '"' || char === "'") { + quote = char; + } else if (char === ">") { + break; + } + cursor += 1; + } + if (cursor >= markup.length) break; + + const inner = markup.slice(next + 1, cursor); + index = cursor + 1; + + const closing = inner.startsWith("/"); + const body = closing ? inner.slice(1) : inner; + const selfClosing = body.endsWith("/"); + const normalized = selfClosing ? body.slice(0, -1) : body; + const nameEnd = normalized.search(/[\s/]/); + const tag = (nameEnd === -1 ? normalized : normalized.slice(0, nameEnd)).toLowerCase(); + if (tag === "") continue; + + if (skipDepth > 0) { + // Inside a cut subtree: track only the tag that opened it, so a `
` + // nested in a dropped `", + ); + const externalRefs = [...documentOnly.matchAll(/<(?:script|link)[^>]*(?:src|href)="([^"]+)"/g)] + .map((match) => match[1]) + .filter((url) => !url.startsWith("data:")); + expect(externalRefs).toEqual([]); + + await clientTransport.close(); + await serverTransport.close(); + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx new file mode 100644 index 0000000000..9a585dea48 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx @@ -0,0 +1,432 @@ +// `virtual:executor-mcp-apps-shell-html` is supplied by `mcpAppsShellAsset` +// (`@executor-js/mcp-apps-shell/vite`). Referenced explicitly — and first, as +// triple-slash directives are only honored above the imports — so the ambient +// declaration travels with this file into consumers that bundle it; a +// consumer's tsconfig `include` covers only its own sources. +/// +import { useCallback, useEffect, useRef, useState } from "react"; +import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { ArtifactRendererProps } from "@executor-js/react/api/artifact-renderer"; + +import shellHtmlUrl from "virtual:executor-mcp-apps-shell-html"; +// From `./preview-capture`, NOT from `./shell-app`: that module imports the +// shell build's virtual modules (`virtual:executor-tailwind-browser` and +// friends), which do not exist in the console's bundle — importing a constant +// from it drags them in and the artifact renderer fails to load entirely. +import { PREVIEW_CAPTURE_HOST_CONTEXT_KEY, SAVE_ARTIFACT_PREVIEW_TOOL } from "./preview-capture"; +// Same import-free-module reasoning as `./preview-capture`: the vocabulary for +// "the artifact is on screen now", shared by the shell document that sends it +// and this host, which is the one host that holds a loading surface until it +// arrives. +import { ARTIFACT_RENDERED_TOOL, RENDER_SIGNAL_HOST_CONTEXT_KEY } from "./render-signal"; +// Same reasoning as `./preview-capture`: a module with no build-graph imports of +// its own, so both the host half (here) and the app half (`shell-app.tsx`) can +// agree on the vocabulary without either dragging in the other's virtual +// modules. +import { FILL_DISPLAY_MODE, INLINE_DISPLAY_MODE } from "./display-mode"; + +/** + * Hosts the MCP-Apps shell on the console's artifact page. + * + * The shell is NOT a React component this page mounts. It is a self-contained + * DOCUMENT — the very bytes served as the `ui://executor/shell.html` resource — + * loaded into a sandboxed iframe, with this component implementing the HOST half + * of the MCP Apps protocol against it. The artifact page is therefore an MCP + * Apps client like any other, and the shell runs in one configuration + * everywhere. + * + * That is not architectural purity for its own sake; mounting `McpAppsShell` + * inline was actively broken: + * + * - It imports `@tailwindcss/browser` and its own `globals.css` at IMPORT + * scope, and it themes by toggling `document.documentElement`. Inline, all + * of that landed on the CONSOLE document — the shell's palette (a teal + * `--primary`) overwrote the console's strictly-grayscale tokens for the + * rest of the session, and its runtime Tailwind JIT kept mutating the page. + * - Its inner renderer reports content height over the MCP-Apps resize + * protocol, which expects a host frame to consume it. With no host, nothing + * did, so tall artifacts clipped mid-viewport. + * + * Both symptoms are one root cause — a document-scoped program mounted without + * its document — and both are fixed by giving it one. + * + * This module is still BROWSER-ONLY (it builds a postMessage bridge against a + * live iframe), so the `ArtifactRendererProvider` loader seam is unchanged: apps + * register it through a dynamic `import()` that only ever resolves in the + * browser, and the page holds it behind `ClientOnly` + `Suspense`. It stays the + * DEFAULT export because the seam hands the loader straight to `React.lazy`. + */ + +/** What the page passes as `host` — see `createHttpShellHost` in + * `@executor-js/react/api/shell-host`. Declared structurally because that + * package depends on THIS one for its component barrel, so importing its type + * here would close a package cycle turbo rejects outright. */ +type HttpShellHost = { + readonly callServerTool: (params: { + name: string; + arguments?: Record; + }) => Promise; + readonly getHostContext: () => { readonly theme: "light" | "dark" } | undefined; + readonly openLink: (params: { url: string }) => Promise; + /** Store a settled-render snapshot as this artifact's gallery preview. */ + readonly savePreview: (artifactId: string, preview: string) => void; +}; + +/** The frame height before the app has reported its content size, in the inline + * fallback path. Tall enough that the shell's own loading state is not itself + * clipped. */ +const INITIAL_FRAME_HEIGHT = 320; + +/** + * A floor under the measured viewport. + * + * The container is measured, not guessed, so this only guards the degenerate + * readings — a container measured while the page is still laying out, or a + * window dragged to a sliver. Below this there is no room for a header and a + * scroll region both, and a frame of a few pixels reads as a broken page. + */ +const MIN_FRAME_HEIGHT = 160; + +/** + * The console's active theme, resolved the way the console's own CSS resolves + * it: an explicit `.dark` class wins, otherwise the OS preference. + */ +const readDocumentTheme = (): "light" | "dark" => { + if (document.documentElement.classList.contains("dark")) return "dark"; + return globalThis.window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +}; + +/** + * What this host tells the app about the surface it has. + * + * The detail page is the one place an artifact is not embedded in a flow of + * other content — it IS the page — so it advertises the spec's `fullscreen` + * mode and hands over a FIXED `containerDimensions.height`. Together those two + * fields are the whole negotiation: the mode says "you own this surface", the + * fixed height says how much of it there is. See `./display-mode` for why + * `fullscreen` rather than a mode of our own, and why a fixed `height` rather + * than a `maxHeight`. + * + * `availableDisplayModes` lists both, truthfully: this host can render an + * artifact either way, and an app that asks to go back to `inline` is asking + * for something the page could honor. + * + * Before the container has been measured (`viewport === null`) the page has not + * yet laid out, and claiming a surface whose size is unknown would have the app + * resolve `height: 100%` against nothing. So until then this is an ordinary + * inline host, which is also what makes the mode switch a no-op for a viewport + * that never resolves. + */ +const hostContextFor = (theme: "light" | "dark", viewport: number | null): McpUiHostContext => ({ + theme, + displayMode: viewport === null ? INLINE_DISPLAY_MODE : FILL_DISPLAY_MODE, + availableDisplayModes: [INLINE_DISPLAY_MODE, FILL_DISPLAY_MODE], + platform: "web", + ...(viewport === null ? {} : { containerDimensions: { height: viewport } }), + // The console is the one host with somewhere to put a preview, so it is the + // one host that asks for one. Carried on the spec's open index signature, + // which is what it exists for; every other host omits it and the shell + // skips the capture entirely. + [PREVIEW_CAPTURE_HOST_CONTEXT_KEY]: true, + // The console holds ONE skeleton from navigation until the artifact paints, + // so it is the one host that needs telling when that is. See + // `./render-signal` for why the shell cannot infer it and why no size report + // is the same fact. + [RENDER_SIGNAL_HOST_CONTEXT_KEY]: true, +}); + +export default function ArtifactShell(props: ArtifactRendererProps) { + const frameRef = useRef(null); + const containerRef = useRef(null); + /** + * The measured height of the page's content area, or `null` before the first + * measurement. + * + * This — not the app's reported content height — is the frame's height. That + * inversion is the fix: the artifact page gives the artifact a viewport and + * holds it there, so the artifact's own `flex-1 min-h-0 overflow-auto` has + * something to resolve against and its header can stay put while its table + * scrolls underneath. + */ + const [viewport, setViewport] = useState(null); + /** + * The app's own reported content height — the inline story, kept for the case + * where the container never resolves to a usable size (a host embedding this + * component somewhere with no bounded height of its own). On the detail page + * the observer resolves immediately and this is never read. + */ + const [contentHeight, setContentHeight] = useState(INITIAL_FRAME_HEIGHT); + const [theme, setTheme] = useState<"light" | "dark">(() => readDocumentTheme()); + + // Read through refs inside the bridge: the bridge is built once per artifact, + // and rebuilding it when the host identity changes would reload the iframe and + // discard the rendered component. + const hostRef = useRef(props.host as HttpShellHost); + hostRef.current = props.host as HttpShellHost; + const codeRef = useRef(props.code); + codeRef.current = props.code; + const artifactIdRef = useRef(props.artifactId); + artifactIdRef.current = props.artifactId; + // The page's "you can drop the skeleton now" callback, read through a ref for + // the same reason as the host: the bridge is built once per artifact, and + // capturing the prop would pin whichever function existed then. + const onRenderedRef = useRef(props.onRendered); + onRenderedRef.current = props.onRendered; + // The bridge is built once per artifact and must see the CURRENT viewport, + // both when it opens (so its initial host context is already the fill one) + // and inside `onsizechange` (so it knows to ignore the report). + const viewportRef = useRef(viewport); + viewportRef.current = viewport; + + const bridgeRef = useRef(null); + + // Track the console's theme and forward it as host context, which is what + // drives the shell's own `applyTheme` — inside the iframe's document, where it + // belongs, rather than on the console's `documentElement`. + useEffect(() => { + const media = globalThis.window.matchMedia?.("(prefers-color-scheme: dark)"); + const sync = () => setTheme(readDocumentTheme()); + media?.addEventListener("change", sync); + const observer = new MutationObserver(sync); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); + return () => { + media?.removeEventListener("change", sync); + observer.disconnect(); + }; + }, []); + + /** + * Measure the surface the page has given this artifact, and keep measuring. + * + * A `ResizeObserver` on the container rather than a `window` resize listener: + * the content area is a flex child of the console's own layout, so it changes + * height for reasons that never touch the window — the sidebar collapsing, a + * banner appearing above it. Observing the box that actually holds the frame + * catches all of those and the window resize alike. + * + * `borderBoxSize` is read in preference to `contentRect` so a container that + * ever gains padding or a border still reports the space the frame occupies. + */ + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const measure = (next: number) => { + const bounded = Math.max(MIN_FRAME_HEIGHT, Math.floor(next)); + // Guard the state write, not just the render: an observer that fires on + // every sub-pixel reflow would otherwise push a new host context — and + // therefore a `ui/notifications/host-context-changed` — into the frame + // continuously while a user drags a window edge. + setViewport((prev) => (prev === bounded ? prev : bounded)); + }; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const borderBox = entry.borderBoxSize?.[0]?.blockSize; + measure(typeof borderBox === "number" ? borderBox : entry.contentRect.height); + }); + observer.observe(container); + // Seed from the current layout rather than waiting for the observer's first + // callback, so the very first host context the bridge is built with already + // carries a viewport and the app never renders a frame in the wrong mode. + measure(container.getBoundingClientRect().height); + + return () => observer.disconnect(); + }, []); + + // Both the theme and the viewport travel on the same context, and + // `setHostContext` diffs for us — it notifies the app only about fields that + // actually changed, so a resize does not re-announce the theme. + useEffect(() => { + bridgeRef.current?.setHostContext(hostContextFor(theme, viewport)); + }, [theme, viewport]); + + /** + * Build and connect the bridge the moment the iframe ELEMENT mounts — as the + * frame's `ref`, not its `onLoad`. + * + * This ordering is the whole correctness argument, so it is worth stating + * exactly. `ui/initialize` is a fire-and-forget `postMessage`: the app's + * `connect()` sends it once, ext-apps has no retry, no backoff and no + * readiness handshake, and `AppBridge` buffers nothing. The host's transport + * only begins listening inside `bridge.connect()`, whose last synchronous step + * is `window.addEventListener("message", …)`. So a `ui/initialize` posted + * while the host has no listener is delivered to a window that ignores it and + * is gone for good — both sides then wait forever, which is the shell stuck on + * "Connecting" (the app's request does eventually time out after the SDK's 60s + * default, but the host has long since stopped being able to answer). + * + * Connecting in `onLoad` lost that race. The frame's `load` event fires AFTER + * its document has been parsed and its module scripts have run, so the app + * could — and on a cold, dev-served, 4.5MB document did — post `ui/initialize` + * before the host was listening. It "worked" locally only because the two + * landed within a few milliseconds of each other. + * + * A ref callback runs during the commit phase, synchronously after React has + * inserted the element into the document and therefore before the browser has + * fetched a single byte of `src`. `contentWindow` is already non-null there + * (it is the initial `about:blank` window), and a same-origin navigation keeps + * the browsing context's WindowProxy identity — so both the transport's + * `postMessage` target and its `event.source` filter still refer to the shell + * document once it replaces the placeholder. The listener is thus provably up + * before any script in the frame can run, whatever the machine, cache state or + * document size. This is also how sunpeak's host does it, which is why real + * MCP-Apps hosts never saw this bug. + * + * The returned cleanup makes this callback the SOLE owner of the bridge's + * lifecycle. Previously an effect keyed on `props.code` closed + * `bridgeRef.current`, which is a second, unsynchronized owner: under a + * StrictMode double-invoke or any re-render that reorders effects against + * refs, that cleanup could close a bridge a later mount had just connected, + * stranding the shell on "Connecting" for an entirely different reason. Now + * each bridge is created and closed by the same attach/detach pair and can + * only ever close itself. + */ + const attachFrame = useCallback((frame: HTMLIFrameElement | null): (() => void) | undefined => { + frameRef.current = frame; + const contentWindow = frame?.contentWindow; + if (!contentWindow) return undefined; + + const bridge = new AppBridge( + // No MCP client: the artifact page reaches the server over the ordinary + // executions HTTP API, so every app-originated call is answered by the + // handlers below rather than proxied onto an MCP connection. + null, + { name: "Executor Console", version: "1.0.0" }, + { openLinks: {}, serverTools: {} }, + { hostContext: hostContextFor(readDocumentTheme(), viewportRef.current) }, + ); + + // Deliver the stored source the way a real host does after `create-artifact`: the + // tool input, then the tool result carrying `structuredContent.code` — the + // exact shape `renderedInAppResult` builds in the MCP host. The shell + // already handles both, so it takes the same path here as it does under any + // other MCP-Apps client, with no artifact-page-only branch. + bridge.oninitialized = () => { + const code = codeRef.current; + const artifactId = artifactIdRef.current; + void bridge + .sendToolInput({ arguments: { code, artifactId } }) + .then(() => + bridge.sendToolResult({ + content: [{ type: "text", text: "Rendered saved artifact." }], + structuredContent: { code, artifactId }, + }), + ) + .catch((error: unknown) => { + console.error("[executor-console] Failed to deliver the artifact to the shell:", error); + }); + }; + + /** + * The app keeps reporting its content height. In fill mode this host + * deliberately does nothing with it. + * + * That is the clean division the mode buys: the HOST owns the mode, so the + * app does not need a branch for "am I being measured" — it reports its size + * truthfully in every mode, exactly as the protocol says, and a host that + * has already decided the frame's height simply does not consume the report. + * Acting on it here would undo the whole fix, since the app's content is now + * as tall as the viewport it was given and growing the frame to match would + * chase itself. + * + * Inline hosts — every chat client — are untouched: the frame grows, the + * page scrolls, nothing clips. + */ + bridge.onsizechange = (params) => { + if (viewportRef.current !== null) return; + if (typeof params.height !== "number") return; + setContentHeight(Math.max(MIN_FRAME_HEIGHT, Math.ceil(params.height))); + }; + + bridge.oncalltool = (params) => { + // The shell's preview upgrade is not a server tool; it is this host's own + // call, answered here. Intercepted before the tool transport so it never + // reaches `/executions` — a snapshot is not an execution and must not be + // metered, approved or logged as one. + if (params.name === SAVE_ARTIFACT_PREVIEW_TOOL) { + const artifactId = params.arguments?.["artifactId"]; + const preview = params.arguments?.["preview"]; + if (typeof artifactId === "string" && typeof preview === "string") { + hostRef.current.savePreview(artifactId, preview); + } + return Promise.resolve({ content: [] } satisfies CallToolResult); + } + + // "The artifact is on screen." Answered here for the same reason the + // preview is: it is this host's own call, not a server tool, and it must + // never reach `/executions`. The page uses it to take its skeleton down + // — see `ArtifactStage`. + if (params.name === ARTIFACT_RENDERED_TOOL) { + onRenderedRef.current?.(); + return Promise.resolve({ content: [] } satisfies CallToolResult); + } + return hostRef.current.callServerTool({ + name: params.name, + ...(params.arguments ? { arguments: params.arguments } : {}), + }); + }; + + bridge.onopenlink = async (params) => { + await hostRef.current.openLink({ url: params.url }); + return {}; + }; + + void bridge + .connect(new PostMessageTransport(contentWindow, contentWindow)) + .catch((error: unknown) => { + console.error("[executor-console] Artifact shell failed to connect:", error); + }); + + bridgeRef.current = bridge; + + // Detach: close THIS bridge, and only clear the shared ref if it is still + // the current one, so a bridge that has already been replaced can never + // null out its successor. + return () => { + void bridge.close(); + if (bridgeRef.current === bridge) bridgeRef.current = null; + if (frameRef.current === frame) frameRef.current = null; + }; + }, []); + + return ( + // The measured box. `h-full` against the page's content area is what makes + // the measurement meaningful: the container is exactly the room the page has + // given the artifact, so the number the observer reads is the number the + // frame should be. `min-h-0` lets it shrink inside the page's flex column + // rather than being floored at its content's height. +
+ + + +`; + +const startShellServer = async (): Promise => { + const server = await createViteServer({ + configFile: resolve(packageRoot, "vite.config.shell.ts"), + clearScreen: false, + logLevel: "error", + server: { + host: "127.0.0.1", + port: 0, + }, + }); + + await server.listen(); + const url = server.resolvedUrls?.local[0]; + if (!url) { + throw new Error("Vite did not report a local shell URL."); + } + + return { + url: url.replace(/\/$/, ""), + close: () => server.close(), + }; +}; + +const readBody = (request: IncomingMessage): Promise => + new Promise((resolveBody, rejectBody) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.from(chunk)); + }); + request.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8"))); + request.on("error", rejectBody); + }); + +const startOpenApiServer = (): Promise => + new Promise((resolveServer, rejectServer) => { + let baseUrl = ""; + let domainAutoRenew = false; + let nextDomainGetDelayMs = 0; + const postRequests: string[] = []; + + const server: Server = createServer(async (request, response) => { + if (request.method === "GET" && request.url === "/openapi.json") { + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Inventory", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/items": { + get: { + operationId: "listItems", + responses: { + "200": { + description: "Inventory items", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Item" }, + }, + }, + }, + }, + }, + }, + post: { + operationId: "createItem", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreateItem" }, + }, + }, + }, + responses: { + "200": { + description: "Created item", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreatedItem" }, + }, + }, + }, + }, + }, + }, + "/domains/{domain}": { + get: { + operationId: "getDomain", + parameters: [ + { + name: "domain", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Domain", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Domain" }, + }, + }, + }, + }, + }, + }, + "/domains/{domain}/auto-renew": { + post: { + operationId: "updateDomainAutoRenew", + parameters: [ + { + name: "domain", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AutoRenewInput" }, + }, + }, + }, + responses: { + "200": { + description: "Updated domain", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Domain" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + AutoRenewInput: { + type: "object", + required: ["autoRenew"], + properties: { + autoRenew: { type: "boolean" }, + }, + }, + Domain: { + type: "object", + required: ["domain", "renew"], + properties: { + domain: { type: "string" }, + renew: { type: "boolean" }, + }, + }, + Item: { + type: "object", + required: ["id", "name"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + }, + }, + CreateItem: { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }, + CreatedItem: { + type: "object", + required: ["id", "name", "created"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + created: { type: "boolean" }, + }, + }, + }, + }, + }), + ); + return; + } + + if (request.method === "GET" && request.url === "/items") { + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify([{ id: 1, name: "Seed Widget" }])); + return; + } + + if (request.method === "GET" && request.url?.startsWith("/domains/")) { + const domain = decodeURIComponent(request.url.slice("/domains/".length)); + if (!domain.includes("/")) { + const delayMs = nextDomainGetDelayMs; + nextDomainGetDelayMs = 0; + if (delayMs > 0) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)); + } + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ domain, renew: domainAutoRenew })); + return; + } + } + + if (request.method === "POST" && request.url === "/items") { + const body = await readBody(request); + postRequests.push(body); + const parsed = JSON.parse(body) as { name?: unknown }; + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + id: 2, + name: typeof parsed.name === "string" ? parsed.name : "Unnamed", + created: true, + }), + ); + return; + } + + if ( + request.method === "POST" && + request.url?.startsWith("/domains/") && + request.url.endsWith("/auto-renew") + ) { + const body = await readBody(request); + postRequests.push(body); + const parsed = JSON.parse(body) as { autoRenew?: unknown }; + domainAutoRenew = parsed.autoRenew === true; + nextDomainGetDelayMs = 300; + const domainPath = request.url.slice( + "/domains/".length, + request.url.length - "/auto-renew".length, + ); + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + domain: decodeURIComponent(domainPath), + renew: domainAutoRenew, + }), + ); + return; + } + + response.statusCode = 404; + response.end("not found"); + }); + + server.once("error", rejectServer); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectServer); + const address = server.address(); + if (!address || typeof address === "string") { + rejectServer(new Error("Failed to resolve OpenAPI server address.")); + return; + } + + const { port } = address as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; + resolveServer({ + specUrl: `${baseUrl}/openapi.json`, + postRequests, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((err) => (err ? rejectClose(err) : resolveClose())); + }), + }); + }); + }); + +const makePausedResult = ( + id: string, + request: ReturnType, +): ExecutionResult => ({ + status: "paused", + execution: { id, elicitationContext: { address: formToolId, args: {}, request } }, +}); + +/** + * These tests drive the shell directly over postMessage rather than through + * `create-artifact`, so persistence is out of scope here — but the ui tools only + * register when an artifacts port is present, and `execute-action` is what the + * shell actually calls. A trivial in-memory port is enough to bring them up. + * + * Every artifact this port mints is pre-bound to the harness's one `inventory` + * connection, under both the implicit role (`inventory`) and an explicit `alt` + * role. That is what lets a generated component here address the short form + * `tools.inventory.listItems` and the role form `tools.inventory("alt").…`, and + * still reach `tools.inventory.*` on the executor — the same rewrite a + * real artifact gets from its stored bindings. + */ +/** The artifact every harness component renders as. Pre-seeded so a test can + * hand its id to `execute-action` exactly as the host does. */ +const HARNESS_ARTIFACT_ID = "art_1"; + +const HARNESS_BINDINGS: ArtifactBindings = { + inventory: { + integration: IntegrationSlug.make(INVENTORY_SLUG), + owner: "org", + connection: ConnectionName.make(INVENTORY_CONNECTION), + }, + alt: { + integration: IntegrationSlug.make(INVENTORY_SLUG), + owner: "org", + connection: ConnectionName.make(INVENTORY_CONNECTION), + }, + // Used by the elicitation fixtures, which run against a stub engine that + // answers any address rather than the real inventory executor. + profile: { + integration: IntegrationSlug.make("profile"), + owner: "org", + connection: ConnectionName.make("main"), + }, +}; + +const makeInMemoryArtifacts = () => { + const rows = new Map(); + let seq = 0; + // Pre-seeded, because these tests push code into the shell directly rather + // than through `create-artifact`. The row exists so `execute-action` has + // bindings to resolve against, the way it would for any real artifact. + rows.set(HARNESS_ARTIFACT_ID, { + id: ArtifactId.make(HARNESS_ARTIFACT_ID), + owner: "user", + title: "Harness", + description: null, + code: "", + bindings: HARNESS_BINDINGS, + preview: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }); + return { + list: (): Effect.Effect => Effect.sync(() => [...rows.values()]), + get: (id: string): Effect.Effect => + Effect.suspend(() => { + const row = rows.get(id); + return row ? Effect.succeed(row) : Effect.fail(new Error(`no artifact ${id}`)); + }), + save: (input: { + readonly title: string; + readonly description?: string | null; + readonly code: string; + readonly bindings?: ArtifactBindings | null; + readonly preview?: string | null; + }): Effect.Effect => + Effect.sync(() => { + seq += 1; + const artifact = { + id: ArtifactId.make(`art_${seq}`), + owner: "user" as const, + title: input.title, + description: input.description ?? null, + code: input.code, + bindings: input.bindings ?? HARNESS_BINDINGS, + preview: + input.preview === undefined || input.preview === null + ? null + : ({ kind: "layout", markup: input.preview } as const), + createdAt: new Date(seq * 1000), + updatedAt: new Date(seq * 1000), + }; + rows.set(artifact.id, artifact); + return artifact; + }), + }; +}; + +const startMcpHarnessForEngine = async ( + engine: ExecutionEngine, +): Promise => { + const mcpServer = await Effect.runPromise( + createExecutorMcpServer({ + engine, + loadAppShellHtml: loadMcpAppsShellHtml, + artifacts: makeInMemoryArtifacts(), + // Artifacts are opt-in per connection; this harness drives the artifact + // tools, so it connects the way a `?artifacts=true` client does. + artifactsEnabled: true, + }), + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "browser-harness", version: "1.0.0" }, + { capabilities: appsWithoutElicitationCapabilities }, + ); + + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + + return { + callTool: async (params) => { + if (!params.name) { + throw new Error("Missing MCP tool name."); + } + return client.callTool({ + name: params.name, + arguments: params.arguments ?? {}, + }); + }, + close: async () => { + await clientTransport.close(); + await serverTransport.close(); + }, + }; +}; + +const startMcpHarness = async (openApi: OpenApiServer): Promise => { + const executor = await Effect.runPromise( + Effect.gen(function* () { + const built = yield* createExecutor( + makeTestConfig({ plugins: [inventoryPlugin(openApi.postRequests)] }), + ); + // Tools only exist per connection, so register the integration and open + // one org `main` connection — that is what makes + // `tools.inventory.*` resolvable from generated code. + yield* built["inventory-test"].seed(); + yield* built.connections.create({ + owner: "org", + name: INVENTORY_CONNECTION, + integration: INVENTORY_SLUG, + template: INVENTORY_TEMPLATE, + value: "token", + }); + return built; + }), + ); + + const engine = createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor({ + timeoutMs: 5_000, + memoryLimitBytes: 32 * 1024 * 1024, + }), + }); + + return startMcpHarnessForEngine(engine); +}; + +const startSchemaElicitationMcpHarness = (): Promise => + startMcpHarnessForEngine({ + getDescription: Effect.succeed("schema elicitation test executor"), + execute: () => Effect.succeed({ result: null }), + executeWithPause: () => + Effect.succeed( + makePausedResult( + "schema_exec", + FormElicitation.make({ + message: "Provide approval details", + requestedSchema: { + type: "object", + required: ["name", "count", "priority", "tags"], + properties: { + name: { + type: "string", + title: "Display name", + minLength: 2, + description: "Human-readable name to submit.", + }, + count: { + type: "integer", + title: "Count", + minimum: 1, + default: 2, + }, + priority: { + type: "string", + title: "Priority", + enum: ["low", "high"], + enumNames: ["Low", "High"], + }, + notify: { + type: "boolean", + title: "Notify", + default: false, + }, + tags: { + type: "array", + title: "Tags", + minItems: 1, + items: { + enum: ["alpha", "beta"], + enumNames: ["Alpha", "Beta"], + }, + }, + }, + }, + }), + ), + ), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + resume: (_executionId, response) => + Effect.succeed({ + status: "completed", + result: { result: response.content ?? {} }, + }), + }); + +const startHostServer = (shellUrl: string, mcp: McpHarness): Promise => + new Promise((resolveServer, rejectServer) => { + const html = createHostHtml(shellUrl); + const server: Server = createServer(async (request, response) => { + if (request.method === "POST" && request.url === "/tools/call") { + try { + const body = await readBody(request); + const params = JSON.parse(body) as HostToolCall; + const result = await mcp.callTool(params); + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(result)); + } catch (err) { + response.statusCode = 500; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + content: [ + { + type: "text", + text: err instanceof Error ? err.message : String(err), + }, + ], + isError: true, + }), + ); + } + return; + } + + if (request.method !== "GET" || request.url !== "/") { + response.statusCode = 404; + response.end("not found"); + return; + } + + response.statusCode = 200; + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(html); + }); + + server.once("error", rejectServer); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectServer); + const address = server.address(); + if (!address || typeof address === "string") { + rejectServer(new Error("Failed to resolve host server address.")); + return; + } + + const { port } = address as AddressInfo; + resolveServer({ + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((err) => (err ? rejectClose(err) : resolveClose())); + }), + }); + }); + }); + +const waitForValue = async ( + page: Page, + read: () => T | undefined, + label: string, +): Promise => { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const value = read(); + if (value !== undefined) return value; + await page.waitForTimeout(50); + } + throw new Error(`Timed out waiting for ${label}.`); +}; + +const waitForShellFrame = (page: Page): Promise => + waitForValue( + page, + () => page.frames().find((frame) => frame.url().includes("/mcp-app.html")), + "MCP app shell iframe", + ); + +const waitForInnerFrame = async (page: Page, shellFrame: Frame): Promise => { + const locator = shellFrame.locator('iframe[title="Generated UI"]'); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const handle = await locator.elementHandle(); + const frame = await handle?.contentFrame(); + await handle?.dispose(); + if (frame) return frame; + await page.waitForTimeout(50); + } + throw new Error("Timed out waiting for generated UI iframe."); +}; + +const waitForHostInitialized = (page: Page) => + page.waitForFunction(() => + Boolean((window as unknown as BrowserHostWindow).__mcpHostState.initialized), + ); + +const getHostState = (page: Page): Promise => + page.evaluate(() => (window as unknown as BrowserHostWindow).__mcpHostState); + +const openHarness = async (browser: Browser, hostUrl: string) => { + const page = await browser.newPage(); + await page.goto(hostUrl, { waitUntil: "domcontentloaded" }); + const shellFrame = await waitForShellFrame(page); + await waitForHostInitialized(page); + await shellFrame.locator('[data-testid="shell-loading-state"]').waitFor({ timeout: 10_000 }); + return { page, shellFrame }; +}; + +const renderGeneratedUi = async (page: Page, shellFrame: Frame, code: string): Promise => { + await page.evaluate( + ([value, artifactId]) => + (window as unknown as BrowserHostWindow).__sendGeneratedUi(value!, artifactId!), + [code, HARNESS_ARTIFACT_ID] as const, + ); + await shellFrame.locator('iframe[title="Generated UI"]').waitFor({ timeout: 10_000 }); + return waitForInnerFrame(page, shellFrame); +}; + +describe("MCP app generated UI browser isolation", () => { + let openApiServer: OpenApiServer | undefined; + let mcpHarness: McpHarness | undefined; + let shellServer: ShellServer | undefined; + let hostServer: HostServer | undefined; + let browser: Browser | undefined; + + beforeAll(async () => { + openApiServer = await startOpenApiServer(); + mcpHarness = await startMcpHarness(openApiServer); + shellServer = await startShellServer(); + hostServer = await startHostServer(shellServer.url, mcpHarness); + browser = await chromium.launch({ + executablePath: chromeExecutablePath, + headless: process.env.PLAYWRIGHT_HEADLESS !== "0", + slowMo: process.env.PLAYWRIGHT_SLOWMO ? Number(process.env.PLAYWRIGHT_SLOWMO) : undefined, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + }, 60_000); + + afterAll(async () => { + await browser?.close(); + await hostServer?.close(); + await shellServer?.close(); + await mcpHarness?.close(); + await openApiServer?.close(); + }, 30_000); + + it("runs generated UI in a sandboxed browser iframe and proxies live tool calls", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedDataCode); + await innerFrame.waitForFunction( + () => document.querySelector("#status")?.textContent === "Widget", + undefined, + { timeout: 10_000 }, + ); + + const rendererAttributes = await shellFrame + .locator('iframe[title="Generated UI"]') + .evaluate((element) => ({ + sandbox: element.getAttribute("sandbox"), + srcDoc: element.getAttribute("srcdoc") ?? "", + })); + + expect(rendererAttributes.sandbox).toBe("allow-scripts"); + expect(rendererAttributes.srcDoc).toContain('meta name="executor-render-token"'); + expect(rendererAttributes.srcDoc).toContain("default-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("connect-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("form-action 'none'"); + expect(rendererAttributes.srcDoc).toContain("frame-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("worker-src 'none'"); + + const parentAccess = await innerFrame.evaluate(() => { + try { + void window.parent.document.body; + return "allowed"; + } catch (err) { + return err instanceof DOMException ? err.name : String(err); + } + }); + expect(parentAccess).toBe("SecurityError"); + + const blockedText = (await innerFrame.locator("#blocked").textContent()) ?? ""; + for (const name of networkPrimitives) { + expect(blockedText).toContain( + `${name} is disabled in generated UI. Use tools.* via useQuery/useMutation.`, + ); + } + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: "return await tools.inventory.listItems({})", + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + // An artifact that uses two accounts of one integration tags each call site + // with a role. The role has to survive four hops — the inner proxy's `apply` + // trap, the TanStack cache key, the postMessage bridge, and the + // `execute-action` grammar — and be what the server resolves the connection + // from. If any hop dropped it, both reads would collapse onto one binding and + // the two calls would be indistinguishable on the wire. + it("carries an integration role from the proxy through to execute-action", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedRoleTaggedCode); + + // Both roles resolved to a real connection and returned real rows. + await innerFrame.waitForFunction( + () => + document.querySelector("#primary")?.textContent !== "loading" && + document.querySelector("#secondary")?.textContent !== "loading", + undefined, + { timeout: 10_000 }, + ); + const primary = await innerFrame.locator("#primary").textContent(); + expect(primary).not.toBe(""); + expect(await innerFrame.locator("#secondary").textContent()).toBe(primary); + + // Two separate calls, each naming its own role — not one shared query. + const roleCalls = (await getHostState(page)).toolCalls + .map((call) => (call.arguments as { code?: string } | undefined)?.code) + .filter((code): code is string => Boolean(code?.includes("listItems"))); + expect(roleCalls).toEqual( + expect.arrayContaining([ + 'return await tools.inventory("inventory").listItems({})', + 'return await tools.inventory("alt").listItems({})', + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + // The design system only exists for the model if the classes it writes turn + // into CSS. They did not: the frame received the shell's COMPILED stylesheet, + // which by construction contains only utilities executor's own components use, + // so anything else silently no-opped — `text-2xl` computed to 16px and + // `md:grid-cols-4` stayed one column. The frame now carries the theme as + // source plus a compiler. This renders classes that appear nowhere in the + // shell's own sources and asserts the COMPUTED result, because "the class is + // in the DOM" was always true even when it did nothing. + it("compiles utility classes the model invents, against executor's tokens", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi( + page, + shellFrame, + `function App() { + return ( +
+
24
+
+ abcd +
+
label
+
muted
+
+ ); + }`, + ); + await innerFrame.locator("#probe").waitFor({ timeout: 10_000 }); + + const computed = await innerFrame.evaluate(() => { + const styleOf = (id: string) => getComputedStyle(document.getElementById(id)!); + return { + fontSize: styleOf("size").fontSize, + gridColumns: styleOf("grid").gridTemplateColumns.split(" ").length, + letterSpacing: styleOf("arbitrary").letterSpacing, + labelSize: styleOf("arbitrary").fontSize, + mutedColor: styleOf("token").color, + bodyFont: getComputedStyle(document.body).fontFamily, + bodyNumeric: getComputedStyle(document.body).fontVariantNumeric, + }; + }); + + // A built-in scale step, a responsive variant, and an arbitrary value — + // three different paths through the compiler, none of them in the shell. + expect(computed.fontSize, "text-2xl resolves to its real size").toBe("24px"); + expect(computed.gridColumns, "md:grid-cols-4 produces four columns").toBe(4); + expect(computed.labelSize, "an arbitrary text size compiles").toBe("11px"); + expect(computed.letterSpacing, "an arbitrary tracking compiles").not.toBe("normal"); + + // Compiled against EXECUTOR's theme, not Tailwind's defaults: the muted + // token is the design system's gray, and the type is Geist. + expect(computed.mutedColor, "muted-foreground is executor's gray").toBe("rgb(102, 102, 102)"); + expect(computed.bodyFont, "the frame is set in Geist").toContain("Geist"); + expect(computed.bodyNumeric, "numbers are tabular by default").toContain("tabular-nums"); + } finally { + await page.close(); + } + }, 30_000); + + it("blocks popup, form, and nested-frame escape attempts from generated UI", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedEscapeAttemptCode); + await innerFrame.locator("#escape-results").waitFor({ timeout: 10_000 }); + + const escapeText = (await innerFrame.locator("#escape-results").textContent()) ?? ""; + expect(escapeText).toContain("popup:true"); + expect(escapeText).toContain("form:"); + expect(escapeText).toContain("iframe:"); + + const pages = browser.contexts().flatMap((context) => context.pages()); + expect(pages).toHaveLength(1); + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toHaveLength(0); + } finally { + await page.close(); + } + }, 30_000); + + it("rejects spoofed renderer messages unless the iframe window and token match", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + // A tool-call request from the wrong window, and one from the right + // window with the wrong token. Neither may reach the bridge. + await shellFrame.evaluate(() => { + const iframe = document.querySelector('iframe[title="Generated UI"]'); + const srcDoc = iframe?.getAttribute("srcdoc") ?? ""; + const token = //.exec(srcDoc)?.[1]; + if (!iframe?.contentWindow || !token) { + throw new Error("Generated UI iframe is missing a token."); + } + + const payload = { + type: "executor.toolCall", + path: ["inventory", "listItems"], + args: [{}], + }; + window.dispatchEvent( + new MessageEvent("message", { + source: window, + data: { ...payload, requestId: 1, token }, + }), + ); + window.dispatchEvent( + new MessageEvent("message", { + source: iframe.contentWindow, + data: { ...payload, requestId: 2, token: "wrong" }, + }), + ); + }); + + await page.waitForTimeout(100); + expect((await getHostState(page)).toolCalls).toHaveLength(0); + + await shellFrame.evaluate(() => { + const iframe = document.querySelector('iframe[title="Generated UI"]'); + const srcDoc = iframe?.getAttribute("srcdoc") ?? ""; + const token = //.exec(srcDoc)?.[1]; + if (!iframe?.contentWindow || !token) { + throw new Error("Generated UI iframe is missing a token."); + } + + window.dispatchEvent( + new MessageEvent("message", { + source: iframe.contentWindow, + data: { + type: "executor.toolCall", + requestId: 3, + token, + path: ["inventory", "listItems"], + args: [{}], + }, + }), + ); + }); + + await page.waitForFunction( + () => (window as unknown as BrowserHostWindow).__mcpHostState.toolCalls.length === 1, + ); + // Even the accepted message becomes the one proxy grammar, never raw code + // — the outer frame builds the source, the inner frame only names a tool. + expect((await getHostState(page)).toolCalls[0]).toEqual( + expect.objectContaining({ + name: "execute-action", + arguments: { + code: "return await tools.inventory.listItems({})", + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ); + } finally { + await page.close(); + } + }, 30_000); + + it("handles elicitations in the trusted shell instead of the generated iframe", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").waitFor({ timeout: 10_000 }); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + expect(await innerFrame.locator("text=Approve action").count()).toBe(0); + expect(await shellFrame.locator('[data-testid="trusted-interaction-fields"]').count()).toBe( + 0, + ); + expect(await shellFrame.locator("text=Response content").count()).toBe(0); + expect(openApiServer.postRequests).toHaveLength(0); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#approval-status")?.textContent === "Approved Widget:true", + undefined, + { timeout: 10_000 }, + ); + expect(openApiServer.postRequests).toEqual([JSON.stringify({ name: "Approved Widget" })]); + + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: expect.any(String), + action: "accept", + content: "{}", + }, + }), + ]); + } finally { + await page.close(); + } + }, 30_000); + + // One refusal control, not two. `buildElicit` and `enforceApproval` in + // `@executor-js/sdk` raise `ElicitationDeclinedError` for ANY non-accept + // action, so "decline" and "cancel" are the same outcome at the gate and the + // modal offers only Cancel. + it("resumes a canceled approval without performing the mutation", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + + const { page, shellFrame } = await openHarness(browser, hostServer.url); + const initialPostCount = openApiServer.postRequests.length; + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + expect(await shellFrame.getByRole("button", { name: "Decline" }).count()).toBe(0); + await shellFrame + .locator('[data-testid="trusted-interaction-cancel"]') + .click({ timeout: 10_000 }); + await page.waitForFunction( + () => (window as unknown as BrowserHostWindow).__mcpHostState.resumeCalls.length === 1, + undefined, + { timeout: 10_000 }, + ); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: expect.any(String), + action: "cancel", + content: "{}", + }, + }), + ]); + } finally { + await page.close(); + } + }, 30_000); + + it("tells the user what they are confirming rather than where the modal came from", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + + const card = shellFrame.locator('[data-testid="trusted-interaction-card"]'); + await card.waitFor({ timeout: 10_000 }); + const cardText = await card.innerText(); + + expect(cardText).toContain("Approve action"); + expect(cardText).toContain("You've triggered a destructive action in the UI, please confirm"); + expect(cardText).not.toContain("This approval is handled by the Executor shell."); + // The concrete request preview stays — copy changed, evidence did not. + expect(cardText).toContain("createItem"); + } finally { + await page.close(); + } + }, 30_000); + + it("stops asking for a tool the user approved for good, and only that tool", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + const modal = shellFrame.locator('[data-testid="trusted-interaction-modal"]'); + + try { + // Remembered grants persist per console origin, so start from a known + // empty state rather than inheriting one from an earlier run. + await shellFrame.evaluate(() => globalThis.localStorage.clear()); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedTwoToolApprovalCode); + const initialPostCount = openApiServer.postRequests.length; + + await innerFrame.locator("#create-first").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-menu"]') + .click({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-always"]') + .click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#create-status")?.textContent === "First Widget", + undefined, + { timeout: 10_000 }, + ); + + // Same tool again: answered from the grant, so no modal ever mounts. + await innerFrame.locator("#create-second").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#create-status")?.textContent === "Second Widget", + undefined, + { timeout: 10_000 }, + ); + expect(await modal.count()).toBe(0); + expect(openApiServer.postRequests.slice(initialPostCount)).toEqual([ + JSON.stringify({ name: "First Widget" }), + JSON.stringify({ name: "Second Widget" }), + ]); + + // A different address on the same artifact is a different action. + await innerFrame.locator("#renew").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve"]') + .click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#renew-status")?.textContent === "false", + undefined, + { timeout: 10_000 }, + ); + + const hostState = await getHostState(page); + expect( + hostState.resumeCalls.map((call) => (call.arguments as { action?: string }).action), + ).toEqual(["accept", "accept", "accept"]); + } finally { + await shellFrame.evaluate(() => globalThis.localStorage.clear()).catch(() => undefined); + await page.close(); + } + }, 60_000); + + it("blocks generated UI mutations that run on mount until trusted approval", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const initialPostCount = openApiServer.postRequests.length; + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedAutoMutationCode); + await innerFrame.locator("#auto-mutation-status").waitFor({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + const hostStateBeforeApproval = await getHostState(page); + expect(hostStateBeforeApproval.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.createItem({"body":{"name":"Mount Widget"}})', + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ]), + ); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#auto-mutation-status")?.textContent === "Mount Widget:true", + undefined, + { timeout: 10_000 }, + ); + expect(openApiServer.postRequests).toHaveLength(initialPostCount + 1); + expect(openApiServer.postRequests.at(-1)).toBe(JSON.stringify({ name: "Mount Widget" })); + } finally { + await page.close(); + } + }, 30_000); + + it("updates live query state after an approved TanStack Query mutation", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const initialPostCount = openApiServer.postRequests.length; + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedTanstackQueryCode); + await innerFrame.waitForFunction( + () => document.querySelector("#auto-renew-state")?.textContent === "false", + undefined, + { timeout: 10_000 }, + ); + + await innerFrame.locator("#auto-renew-toggle").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + + await innerFrame.waitForFunction( + () => document.querySelector("#auto-renew-state")?.textContent === "true", + undefined, + { timeout: 10_000 }, + ); + await innerFrame.waitForFunction( + () => + document.querySelector("#auto-renew-success")?.textContent === + "Auto-renew enabled successfully", + undefined, + { timeout: 10_000 }, + ); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount + 1); + expect(openApiServer.postRequests.at(-1)).toBe(JSON.stringify({ autoRenew: true })); + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.getDomain({"domain":"openexecutor.com"})', + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.updateDomainAutoRenew({"domain":"openexecutor.com","body":{"autoRenew":true}})', + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + it("pages through a multi-page upstream declaratively with useInfiniteQuery", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedInfiniteQueryCode); + + // Page 1 only — an infinite query fetches exactly one page up front. The + // whole point is that "get everything" is the user's/component's choice, + // not an opaque 40-iteration loop hidden inside a queryFn. + await innerFrame.waitForFunction( + () => document.querySelector("#paged-items")?.textContent === "alpha,beta", + undefined, + { timeout: 10_000 }, + ); + expect(await innerFrame.locator("#paged-has-next").textContent()).toBe("true"); + + await innerFrame.locator("#paged-more").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#paged-items")?.textContent === "alpha,beta,gamma,delta", + undefined, + { timeout: 10_000 }, + ); + + await innerFrame.locator("#paged-more").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => + document.querySelector("#paged-items")?.textContent === "alpha,beta,gamma,delta,epsilon", + undefined, + { timeout: 10_000 }, + ); + // `getNextPageParam` returned undefined for the last page, so paging stops. + await innerFrame.waitForFunction( + () => document.querySelector("#paged-has-next")?.textContent === "false", + undefined, + { timeout: 10_000 }, + ); + expect(await innerFrame.locator("#paged-count").textContent()).toBe("3"); + + // Every page went over the wire as an ordinary single tool call — the + // cursor merged into the tool INPUT, not spliced into code. The first + // request carries no cursor at all (`initialPageParam: null`). + const hostState = await getHostState(page); + const pagedCalls = hostState.toolCalls + .map((call) => (call.arguments as { code?: string } | undefined)?.code) + .filter((code): code is string => Boolean(code?.includes("listPaged("))); + expect(pagedCalls).toEqual([ + 'return await tools.inventory.listPaged({"limit":2})', + 'return await tools.inventory.listPaged({"limit":2,"cursor":"2"})', + 'return await tools.inventory.listPaged({"limit":2,"cursor":"4"})', + ]); + } finally { + await page.close(); + } + }, 30_000); + + it("places an infinite-query cursor down a dotted path into a nested input", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi( + page, + shellFrame, + generatedNestedInfiniteQueryCode, + ); + await innerFrame.waitForFunction( + () => document.querySelector("#nested-items")?.textContent === "alpha,beta", + undefined, + { timeout: 10_000 }, + ); + + await innerFrame.locator("#nested-more").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#nested-items")?.textContent === "alpha,beta,gamma,delta", + undefined, + { timeout: 10_000 }, + ); + + const hostState = await getHostState(page); + const nestedCalls = hostState.toolCalls + .map((call) => (call.arguments as { code?: string } | undefined)?.code) + .filter((code): code is string => Boolean(code?.includes("listPagedNested("))); + // The merge is a deep write that preserves the sibling `limit`, rather + // than replacing the nested object. + expect(nestedCalls).toEqual([ + 'return await tools.inventory.listPagedNested({"query":{"limit":2}})', + 'return await tools.inventory.listPagedNested({"query":{"limit":2,"since":"2"}})', + ]); + } finally { + await page.close(); + } + }, 30_000); + + it("refuses arbitrary code posted straight at execute-action", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // The shell offers no way to write this — but the app channel is reachable + // from anything that can speak the bridge, so the SERVER is what has to + // refuse it. Posted through the host's own tools/call path, exactly as a + // hostile frame would. + await renderGeneratedUi(page, shellFrame, generatedStaticCode); + + const rejected = await page.evaluate(async () => { + const response = await fetch("/tools/call", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "execute-action", + arguments: { + code: "let all = []; for (let i = 0; i < 40; i++) { all.push(await tools.inventory.listItems({})) } return all", + }, + }), + }); + return (await response.json()) as { + isError?: boolean; + content?: Array<{ text?: string }>; + }; + }); + + expect(rejected.isError).toBe(true); + expect(rejected.content?.[0]?.text).toContain("single tool call"); + expect(rejected.content?.[0]?.text).toContain("infiniteQueryOptions"); + } finally { + await page.close(); + } + }, 30_000); + + it("renders form elicitations as typed approval fields", async () => { + if (!browser || !shellServer) { + throw new Error("Browser harness did not start."); + } + + const schemaMcpHarness = await startSchemaElicitationMcpHarness(); + const schemaHostServer = await startHostServer(shellServer.url, schemaMcpHarness); + const { page, shellFrame } = await openHarness(browser, schemaHostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedSchemaApprovalCode); + await innerFrame.locator("#ask-schema").click({ timeout: 10_000 }); + await shellFrame.locator("text=Provide approval details").waitFor({ timeout: 10_000 }); + + await shellFrame.getByLabel("Display name").fill("Rhea"); + await shellFrame.getByLabel("Count").fill("3"); + await shellFrame.getByLabel("Priority").selectOption("high"); + await shellFrame.getByLabel("Notify").click(); + await shellFrame.getByLabel("Beta").click(); + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + + await innerFrame.waitForFunction( + () => + document.querySelector("#schema-status")?.textContent === + JSON.stringify({ + name: "Rhea", + count: 3, + priority: "high", + notify: true, + tags: ["beta"], + }), + undefined, + { timeout: 10_000 }, + ); + + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: "schema_exec", + action: "accept", + content: JSON.stringify({ + name: "Rhea", + count: 3, + priority: "high", + notify: true, + tags: ["beta"], + }), + }, + }), + ]); + } finally { + await page.close(); + await schemaHostServer.close(); + await schemaMcpHarness.close(); + } + }, 30_000); + + // "Don't ask again" is consent to skip the prompt, not consent to invent the + // answer. This schema's `name`, `priority` and `tags` are required with no + // defaults, so there is nothing to resume with and the modal must come back. + it("still asks when a remembered tool needs values the user never gave", async () => { + if (!browser || !shellServer) { + throw new Error("Browser harness did not start."); + } + + const schemaMcpHarness = await startSchemaElicitationMcpHarness(); + const schemaHostServer = await startHostServer(shellServer.url, schemaMcpHarness); + const { page, shellFrame } = await openHarness(browser, schemaHostServer.url); + const modal = shellFrame.locator('[data-testid="trusted-interaction-modal"]'); + + try { + await shellFrame.evaluate(() => globalThis.localStorage.clear()); + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedSchemaApprovalCode); + + await innerFrame.locator("#ask-schema").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + await shellFrame.getByLabel("Display name").fill("Rhea"); + await shellFrame.getByLabel("Priority").selectOption("high"); + await shellFrame.getByLabel("Beta").click(); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-menu"]') + .click({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-always"]') + .click({ timeout: 10_000 }); + await modal.waitFor({ state: "detached", timeout: 10_000 }); + + await innerFrame.locator("#ask-schema").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + // The remembered grant did not answer for the user, and nothing resumed + // behind their back. + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toHaveLength(1); + } finally { + await shellFrame.evaluate(() => globalThis.localStorage.clear()).catch(() => undefined); + await page.close(); + await schemaHostServer.close(); + await schemaMcpHarness.close(); + } + }, 60_000); + + /** + * The regression guard for the whole point of fill mode. + * + * What broke before it existed: an artifact's `h-full` resolved against an + * unbounded chain, so `flex-1 min-h-0 overflow-auto` was just a div that grew. + * The table pushed its own header off the top of the screen and the user + * scrolled a document when they wanted an app. + * + * So this asserts the three things that have to be true together, since any + * one of them alone can hold while the layout is still broken: + * + * 1. the scroll region is genuinely bounded and overflowing + * (`scrollHeight > clientHeight`) — not merely tall; + * 2. it actually scrolls when scrolled; + * 3. the artifact's header does NOT move while it does. + */ + it("gives a bounded viewport to an artifact laid out as an app", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // The console's negotiation, verbatim: the spec's `fullscreen` mode plus a + // FIXED container height. Both are required — a mode with no height is not + // a viewport, and the shell deliberately stays inline for it. + await page.evaluate(() => { + (window as unknown as BrowserHostWindow).__setHostContext({ + displayMode: "fullscreen", + availableDisplayModes: ["inline", "fullscreen"], + containerDimensions: { height: 900 }, + }); + }); + await shellFrame.waitForFunction(() => + document.documentElement.classList.contains("artifact-fill"), + ); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedAppLayoutCode); + await innerFrame.locator("#layout-row-119").waitFor({ timeout: 10_000 }); + // The class has to reach the INNER document too — it is the last link in + // the height chain, and the artifact's own `h-full` resolves there. + await innerFrame.waitForFunction(() => + document.documentElement.classList.contains("artifact-fill"), + ); + + const before = await innerFrame.evaluate(() => { + const scroller = document.querySelector("#layout-scroll"); + const header = document.querySelector("#layout-header"); + const thead = document.querySelector("#layout-thead"); + const firstRow = document.querySelector("#layout-row-0"); + if (!scroller || !header || !thead || !firstRow) { + throw new Error("Missing layout elements."); + } + return { + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + headerTop: header.getBoundingClientRect().top, + theadTop: thead.getBoundingClientRect().top, + firstRowTop: firstRow.getBoundingClientRect().top, + bodyScrollHeight: document.body.scrollHeight, + bodyClientHeight: document.body.clientHeight, + }; + }); + + // Bounded: the region is shorter than its content, which is what makes it + // a scroll box rather than a tall div. + expect(before.clientHeight).toBeGreaterThan(0); + expect(before.scrollHeight).toBeGreaterThan(before.clientHeight); + // And the DOCUMENT is not the thing that overflows — the artifact absorbed + // its own content instead of pushing the frame out. + expect(before.bodyScrollHeight).toBeLessThanOrEqual(before.bodyClientHeight + 1); + + const after = await innerFrame.evaluate(() => { + const scroller = document.querySelector("#layout-scroll"); + const header = document.querySelector("#layout-header"); + const thead = document.querySelector("#layout-thead"); + const firstRow = document.querySelector("#layout-row-0"); + if (!scroller || !header || !thead || !firstRow) { + throw new Error("Missing layout elements."); + } + scroller.scrollTop = 400; + return { + scrollTop: scroller.scrollTop, + headerTop: header.getBoundingClientRect().top, + theadTop: thead.getBoundingClientRect().top, + firstRowTop: firstRow.getBoundingClientRect().top, + }; + }); + + // It really scrolled: the rows moved up by the amount scrolled. + expect(after.scrollTop).toBeGreaterThan(0); + expect(before.firstRowTop - after.firstRowTop).toBeCloseTo(after.scrollTop, 0); + // The artifact's own header stayed exactly where it was — this is the + // "Projects" title and its search box that used to scroll away. + expect(after.headerTop).toBeCloseTo(before.headerTop, 0); + // And the sticky table header stayed with it rather than travelling up + // with the rows. Compared against its own starting position rather than + // the container's edge, since `sticky` pins to the padding box (inside the + // 1px border), not the border box. + expect(after.theadTop).toBeCloseTo(before.theadTop, 0); + } finally { + await page.close(); + } + }, 60_000); + + /** + * The other half of the contract, and the one that protects every chat host: + * with no fill context the shell must behave exactly as it always has — report + * its content height and let the host grow the frame. A regression here would + * clip artifacts inside Claude, where there is no bounded viewport to fill. + */ + it("still grows to content for a host that offers no viewport", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedAppLayoutCode); + await innerFrame.locator("#layout-row-119").waitFor({ timeout: 10_000 }); + + const fillApplied = await shellFrame.evaluate(() => + document.documentElement.classList.contains("artifact-fill"), + ); + expect(fillApplied).toBe(false); + + // The inner document grows past the frame it was given and reports that + // height outward; the scroll region has no bound to scroll inside, which + // is correct — in a thread the transcript is what scrolls. + const metrics = await innerFrame.evaluate(() => ({ + bodyScrollHeight: document.body.scrollHeight, + rootHeight: document.getElementById("root")?.getBoundingClientRect().height ?? 0, + })); + expect(metrics.bodyScrollHeight).toBeGreaterThan(1000); + expect(metrics.rootHeight).toBeGreaterThan(1000); + } finally { + await page.close(); + } + }, 60_000); + + /** + * The render signal, and who is allowed to be affected by it. + * + * The console holds ONE loading surface across the whole open — data fetch, + * renderer chunk, shell boot, compile — and it can only take that surface down + * when the artifact has actually painted. Nothing on the console side can see + * that: the artifact is two sandboxed documents down. So the shell reports it, + * over the `callServerTool` seam, to a host that asked (`executorRenderSignal` + * on the host context). + * + * Two properties, and they matter in opposite directions: + * + * 1. A host that ASKS is told — after the artifact is on screen, not before. + * Firing early would reveal a blank frame, which is the exact defect the + * single surface exists to prevent. + * 2. A host that does NOT ask is completely unaffected: no call it has never + * heard of, and it keeps the shell's own loading card. That is every real + * MCP client, and this is the guard for them — asserted here because the + * default harness context omits the key, exactly like Claude's. + */ + it("tells a host that asked when the artifact painted, and no other host", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // `openHarness` already waited on it: a host that did not ask still gets + // the shell's own loading card, unchanged. + const before = await getHostState(page); + expect( + before.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "a host that never asked is not sent the signal", + ).toBe(false); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + // Still silent: the artifact has painted, but this host did not ask. + const afterRender = await getHostState(page); + expect( + afterRender.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "rendering alone does not make the call", + ).toBe(false); + } finally { + await page.close(); + } + }, 60_000); + + it("signals a console-style host once the artifact is on screen", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // Become the console: ask for the signal the way `ArtifactShell` does. + await page.evaluate(() => { + (window as unknown as BrowserHostWindow).__setHostContext({ + executorRenderSignal: true, + }); + }); + + // The shell drops its own loading card for a host that holds one, so + // there is no second placeholder under the console's skeleton. + await shellFrame + .locator('[data-testid="shell-loading-state"]') + .waitFor({ state: "detached", timeout: 10_000 }); + + const beforeRender = await getHostState(page); + expect( + beforeRender.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "nothing is signalled before there is an artifact to look at", + ).toBe(false); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + await page.waitForFunction(() => + (window as unknown as BrowserHostWindow).__mcpHostState.toolCalls.some( + (call: { name?: string }) => call.name === "executor-artifact-rendered", + ), + ); + + // Exactly once — including across a SECOND delivery, which is what the + // latch actually guards. A host replaces the artifact's code (the console + // does exactly this when the tab regains focus and the row has changed), + // the inner frame remounts and paints again, and the signal must not fire + // a second time: the host has long since taken its skeleton down, and a + // repeat would restart a transition that already finished. + const secondFrame = await renderGeneratedUi(page, shellFrame, generatedSecondRenderCode); + await secondFrame.locator("#second").waitFor({ timeout: 10_000 }); + + const after = await getHostState(page); + const signals = after.toolCalls.filter((call) => call.name === "executor-artifact-rendered"); + expect(signals.length, "the signal is latched, not repeated per render").toBe(1); + + // And it never reached the execution transport: a "you may stop waiting" + // is not an execution and must not be metered, approved or logged as one. + expect( + after.toolCalls.some((call) => call.name === "execute-action"), + "the signal is answered by the host, not proxied to execute-action", + ).toBe(false); + } finally { + await page.close(); + } + }, 60_000); + + /** + * The artifact can paint BEFORE the host says it wants to be told. + * + * "The artifact painted" travels up from the inner frame; "tell me when it + * does" travels down on the host context. They are independent channels and + * their order is not guaranteed — a host that advertises the capability in a + * later `host-context-changed` (rather than in its `ui/initialize` reply) + * arrives second. + * + * The paint never happens twice, so a shell that merely tested the capability + * at the moment of the paint would drop the only notification there was ever + * going to be, and the host would hold its loading surface over a fully + * rendered artifact forever. That is a stuck screen, not a cosmetic glitch, + * which is why the fact is remembered and re-checked rather than tested once. + */ + it("still signals a host that asks only after the artifact has painted", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // Render FIRST, with the harness's default context, which does not ask. + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + const beforeAsking = await getHostState(page); + expect( + beforeAsking.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "nothing is sent to a host that has not asked", + ).toBe(false); + + // Only now does the host ask. The paint it is asking about is already in + // the past. + await page.evaluate(() => { + (window as unknown as BrowserHostWindow).__setHostContext({ + executorRenderSignal: true, + }); + }); + + await page.waitForFunction(() => + (window as unknown as BrowserHostWindow).__mcpHostState.toolCalls.some( + (call: { name?: string }) => call.name === "executor-artifact-rendered", + ), + ); + + const after = await getHostState(page); + expect( + after.toolCalls.filter((call) => call.name === "executor-artifact-rendered").length, + "the remembered paint is reported exactly once", + ).toBe(1); + } finally { + await page.close(); + } + }, 60_000); + + it("keeps trusted approval controls visible in a short host iframe", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + await page.evaluate(() => { + const appFrame = document.getElementById("app") as HTMLIFrameElement | null; + if (!appFrame) throw new Error("Missing app iframe."); + appFrame.style.height = "180px"; + }); + await shellFrame.waitForFunction(() => document.documentElement.clientHeight <= 180); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + const metrics = await shellFrame + .locator('[data-testid="trusted-interaction-modal"]') + .evaluate((modal) => { + const card = modal.querySelector('[data-testid="trusted-interaction-card"]'); + const body = modal.querySelector('[data-testid="trusted-interaction-body"]'); + const footer = modal.querySelector( + '[data-testid="trusted-interaction-footer"]', + ); + if (!card || !body || !footer) throw new Error("Missing trusted modal element."); + + const cardRect = card.getBoundingClientRect(); + const footerRect = footer.getBoundingClientRect(); + return { + bodyOverflowY: getComputedStyle(body).overflowY, + cardBottom: cardRect.bottom, + cardTop: cardRect.top, + footerBottom: footerRect.bottom, + footerTop: footerRect.top, + viewportHeight: document.documentElement.clientHeight, + }; + }); + + expect(metrics.bodyOverflowY).toBe("auto"); + expect(metrics.cardTop).toBeGreaterThanOrEqual(0); + expect(metrics.cardBottom).toBeLessThanOrEqual(metrics.viewportHeight + 1); + expect(metrics.footerTop).toBeGreaterThanOrEqual(0); + expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.viewportHeight + 1); + } finally { + await page.close(); + } + }, 30_000); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html new file mode 100644 index 0000000000..7d347f6830 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html @@ -0,0 +1,12 @@ + + + + + + Executor Shell + + +
+ + + diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx new file mode 100644 index 0000000000..609e4b5e7e --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { App } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpAppsShell } from "./shell-app"; + +/** + * The shell's MCP-Apps entry point: connect, then render whatever the host + * sends. + * + * `tool-input` and `tool-result` are ONE-SHOT notifications. A host is free to + * send them the instant the handshake completes — `AppBridge.oninitialized` + * fires on `ui/notifications/initialized`, which `App.connect()` sends before it + * resolves. `McpAppsShell` registers its handlers in an effect, which cannot run + * until React has mounted it, which is at best a frame later. A prompt host + * therefore delivers the artifact into a void, and the shell sits on its loading + * state forever. + * + * That is not hypothetical: it is exactly what the console's artifact page does, + * and the ext-apps SDK warns about the race by name ("handler registered after + * connect() completed the ui/initialize handshake"). + * + * So the notifications are captured HERE, in `onAppCreated` — the callback the + * SDK provides for precisely this, running before `connect()` — and replayed + * into the shell once it has mounted and registered its own handlers. Buffering + * rather than racing means the shell's handlers stay where they belong (with the + * state they drive) and no host has to know how fast this app mounts. + */ + +type ToolInput = { arguments?: Record }; + +/** What arrived before the shell was ready to hear it. */ +type EarlyDelivery = { + toolInput?: ToolInput; + toolResult?: CallToolResult; + /** Set once the shell has taken over; further notifications go straight through. */ + drain?: (delivery: { toolInput?: ToolInput; toolResult?: CallToolResult }) => void; +}; + +const captureEarlyDelivery = (app: App, early: EarlyDelivery) => { + // `addEventListener`, not the `on*` setters: the shell replaces those with its + // own handlers, and this listener must survive that. + app.addEventListener("toolinput", (params) => { + if (early.drain) early.drain({ toolInput: params }); + else early.toolInput = params; + }); + app.addEventListener("toolresult", (params) => { + const result = params as CallToolResult; + if (early.drain) early.drain({ toolResult: result }); + else early.toolResult = result; + }); +}; + +function ConnectedShellApp() { + const earlyRef = React.useRef({}); + const { app, error: connectionError } = useApp({ + appInfo: { name: "Executor Shell", version: "1.0.0" }, + capabilities: {}, + onAppCreated: (created) => captureEarlyDelivery(created, earlyRef.current), + }); + + // Replay once, after the shell's own handlers are in place. The effect runs + // after the child's effects, which is exactly the ordering this needs. + React.useEffect(() => { + if (!app) return; + const early = earlyRef.current; + const deliver = (delivery: { toolInput?: ToolInput; toolResult?: CallToolResult }) => { + if (delivery.toolInput) app.ontoolinput?.(delivery.toolInput); + if (delivery.toolResult) app.ontoolresult?.(delivery.toolResult); + }; + deliver(early); + early.toolInput = undefined; + early.toolResult = undefined; + early.drain = deliver; + }, [app]); + + if (connectionError) { + return ( +
+
Connection error: {connectionError.message}
+
+ ); + } + + if (!app) { + return ( +
+
Connecting
+
+ ); + } + + return ; +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts b/packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts new file mode 100644 index 0000000000..a5782a46e4 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts @@ -0,0 +1,36 @@ +/** + * The host call that stores a settled-render snapshot. + * + * ## Why this is its own module + * + * Both ends of the capture name it: the shell document sends it, and the + * console's `ArtifactShell` answers it. But `shell-app.tsx` imports + * `virtual:executor-tailwind-browser` and `virtual:executor-inner-renderer`, + * which only exist inside the standalone shell's own Vite build — so importing + * this constant FROM there drags those virtual modules into the console's + * bundle, where nothing supplies them, and the artifact renderer fails to load + * at all. + * + * Keeping the shared name in a module with no imports is what lets both sides + * agree on it without either pulling in the other's build graph. + * + * ## What it is + * + * Not an MCP tool the server advertises. It rides the `callServerTool` seam + * because that seam already exists in every host, and the console intercepts it + * before the tool transport — a snapshot is not an execution and must never be + * metered, approved or logged as one. A host that does not implement the name + * simply rejects the call, which is exactly the fail-quiet behaviour a preview + * upgrade should have. + */ +export const SAVE_ARTIFACT_PREVIEW_TOOL = "executor-save-artifact-preview"; + +/** + * The host-context key by which a host asks for a preview snapshot. + * + * Carried on `McpUiHostContext`'s open index signature, which is the spec's own + * forward-compatibility seam. Only the console sets it — it is the only host + * with somewhere to put a preview — so everywhere else the inner frame skips + * the capture entirely rather than doing the work and dropping it. + */ +export const PREVIEW_CAPTURE_HOST_CONTEXT_KEY = "executorPreviewCapture"; diff --git a/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts b/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts new file mode 100644 index 0000000000..fa3ee9a051 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "@effect/vitest"; +import { PROVIDED_GLOBAL_NAMES } from "@executor-js/execution"; + +import { PROVIDED_SCOPE_NAMES } from "./component-runtime"; + +// The `create-artifact` tool rejects code that redeclares a name the shell already +// binds. That guard lives in the MCP host (which must not import React), so the +// name list is generated into the execution package by +// `scripts/gen-provided-globals.ts`. This test is the pin: add a component to +// `components.ts` without regenerating and it fails here rather than silently +// letting a model shadow a real binding at render time. +describe("provided globals", () => { + it("matches the scope the iframe actually binds", () => { + expect([...PROVIDED_GLOBAL_NAMES].sort()).toEqual([...PROVIDED_SCOPE_NAMES].sort()); + }); + + it("covers the names the shell is built around", () => { + for (const name of [ + "React", + "useState", + "useQuery", + "useInfiniteQuery", + "infiniteQueryOptions", + "tools", + "Card", + "cn", + ]) { + expect(PROVIDED_SCOPE_NAMES).toContain(name); + } + }); + + // The decision this branch encodes: artifact code is purely declarative + // `tools.*`. `run` was the escape hatch for arbitrary code and is gone from + // the model-facing surface entirely — re-adding it to the scope must fail + // here, not quietly reopen the hole. + it("binds no arbitrary-code escape hatch", () => { + expect(PROVIDED_SCOPE_NAMES).not.toContain("run"); + expect(PROVIDED_SCOPE_NAMES).not.toContain("eval"); + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/proxy.ts b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts new file mode 100644 index 0000000000..6d7744d0c6 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts @@ -0,0 +1,181 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export type ToolCallHost = { + readonly callServerTool: (params: { + name: string; + arguments?: Record; + }) => Promise; +}; + +export type TrustedInteraction = { + executionId: string; + interaction: { + kind?: unknown; + message?: unknown; + url?: unknown; + requestedSchema?: unknown; + /** + * The paused call's tool address (`executor.coreTools.policies.create`), + * put on the wire by `formatPausedExecution`. It is the only stable + * identity a pause carries — the execution id is per-call — so it is what + * the shell keys a remembered approval by. + */ + address?: unknown; + }; +}; + +export type TrustedInteractionResponse = { + action: "accept" | "decline" | "cancel"; + content?: Record; +}; + +export type RequestTrustedInteraction = ( + interaction: TrustedInteraction, +) => Promise; + +const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/; + +/** + * The ONE grammar the shell ever puts on the `execute-action` wire: + * + * return await tools.("")?(.)*() + * + * A single proxy-shaped tool call, nothing else — no statements, no loops, no + * composition. The server parses `execute-action` against exactly this shape + * (`parseToolCallCode` in `@executor-js/host-mcp`), so an iframe cannot smuggle + * arbitrary code through the app channel even though the shell UI never offers + * a way to write any. `tool-call-grammar.pin.test.ts` pins the two together. + * + * The path's head is an INTEGRATION, and the optional role tags which of the + * artifact's connections for that integration to use. Neither the tier nor the + * connection name is ever on this wire: the server resolves them from the + * bindings stored on the artifact row. That is the whole point of the short + * form — an iframe that fabricated an address would be naming something the + * artifact was never bound to, and the server would refuse it. + * + * Role and args are both `JSON.stringify` output, so the emission stays + * parseable and a role containing a quote cannot break out of the call. + */ +export function toolCallCode( + path: readonly string[], + args: readonly unknown[], + role?: string, +): string { + if (path.length === 0) throw new Error("Invalid tool path."); + const parts = path.map((part) => { + if (typeof part !== "string" || !TOOL_PATH_SEGMENT.test(part)) { + throw new Error("Invalid tool path."); + } + return part; + }); + if (role !== undefined && (typeof role !== "string" || role.length === 0)) { + throw new Error("Invalid tool role."); + } + const [head, ...rest] = parts; + const tag = role === undefined ? "" : `(${JSON.stringify(role)})`; + const trailer = rest.length > 0 ? `.${rest.join(".")}` : ""; + return `return await tools.${head}${tag}${trailer}(${JSON.stringify(args[0] ?? {})})`; +} + +/** + * Calls one tool through the MCP Apps bridge, resolving any shell-owned + * approval the execution pauses for. + * + * `tools.github.issues.create({ title: "Bug" })` in the generated iframe arrives + * here as `(["github","issues","create"], [{ title: "Bug" }])` and becomes + * `execute-action` with + * `code: "return await tools.github.issues.create({\"title\":\"Bug\"})"`. + * + * `artifactId` rides alongside the code because the server cannot resolve the + * call without it: the path names an integration role, and the bindings that + * turn a role into a connection live on the artifact row. It is supplied by the + * host (from the tool input the artifact was delivered with), never by the + * generated component — and the server checks the caller owns it. + */ +export function createToolCaller( + app: ToolCallHost, + requestTrustedInteraction: RequestTrustedInteraction, + getArtifactId?: () => string | undefined, +): (path: readonly string[], args: readonly unknown[], role?: string) => Promise { + return (path, args, role) => { + const artifactId = getArtifactId?.(); + return app + .callServerTool({ + name: "execute-action", + arguments: { + code: toolCallCode(path, args, role), + ...(artifactId === undefined ? {} : { artifactId }), + }, + }) + .then((r) => resolveToolResult(app, r, requestTrustedInteraction)); + }; +} + +async function resolveToolResult( + app: ToolCallHost, + result: CallToolResult, + requestTrustedInteraction: RequestTrustedInteraction, +): Promise { + if (result.isError) { + const msg = result.content?.find((c) => c.type === "text")?.text ?? "Tool call failed"; + throw new Error(msg); + } + + const structured = result.structuredContent as Record | undefined; + const pending = parseTrustedInteraction(structured); + if (pending) { + const response = await requestTrustedInteraction(pending); + const resumed = await app.callServerTool({ + name: "execute-action-resume", + arguments: { + executionId: pending.executionId, + action: response.action, + content: JSON.stringify(response.content ?? {}), + }, + }); + return resolveToolResult(app, resumed, requestTrustedInteraction); + } + + return unwrapResult(structured) ?? parseTextContent(result); +} + +function parseTrustedInteraction( + structured: Record | undefined, +): TrustedInteraction | null { + if (!structured || structured.status !== "waiting_for_interaction") return null; + if (typeof structured.executionId !== "string") return null; + const interaction = + typeof structured.interaction === "object" && + structured.interaction !== null && + !Array.isArray(structured.interaction) + ? (structured.interaction as TrustedInteraction["interaction"]) + : {}; + return { executionId: structured.executionId, interaction }; +} + +/** + * Unwrap execution result. The kernel wraps results as + * `{ status: "completed", result: , logs: [...] }`. + * Return just the inner result value. + */ +function unwrapResult(structured: Record | undefined | null): unknown { + if ( + structured && + typeof structured === "object" && + "status" in structured && + "result" in structured + ) { + return structured.result; + } + return structured; +} + +function parseTextContent(r: { content?: Array<{ type: string; text?: string }> }): unknown { + const text = r.content?.find((c) => c.type === "text")?.text; + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/render-signal.ts b/packages/hosts/mcp-apps-shell/src/shell/render-signal.ts new file mode 100644 index 0000000000..5f493d53d1 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/render-signal.ts @@ -0,0 +1,60 @@ +/** + * The host call that says "the artifact is on screen now". + * + * ## Why a host needs to be told + * + * Opening an artifact is a three-document journey — the console fetches the + * row, loads the shell document into an iframe, and the shell compiles the JSX + * into a further nested frame. A host that wants to hold ONE loading surface + * across all of it (the console does; see `ArtifactStageSkeleton`) needs to know + * when the last of those has actually painted, and nothing in the MCP-Apps + * protocol says so. `ui/notifications/size-changed` is the closest thing and it + * is not the same fact: the shell document reports its own size while it is + * still booting, long before any artifact exists, and a fill-mode host ignores + * the report entirely. + * + * So the shell says it explicitly, once, when it has something to show. + * + * ## "Rendered" means presented, not successful + * + * The signal fires for the ERROR surfaces too — a compile failure, a component + * that threw, an artifact that rendered fine. All three are "the shell is done + * waiting and the user should be looking at the frame rather than a skeleton", + * which is the only question the host is asking. A signal that fired only on + * success would strand a failed artifact under a loading state forever, which is + * a worse bug than the one this exists to fix. + * + * ## Why this is its own module + * + * Same reason as `./preview-capture`, and it is a hard constraint rather than a + * preference: `shell-app.tsx` imports `virtual:executor-tailwind-browser` and + * `virtual:executor-inner-renderer`, which exist only inside the standalone + * shell's own Vite build. Importing a constant FROM there drags those virtual + * modules into the console's bundle, where nothing supplies them, and the + * artifact renderer then fails to load at all. A module with no imports of its + * own is what lets both halves agree on the vocabulary. + * + * ## Why it rides `callServerTool` + * + * Because that seam already exists in every host, exactly as the preview capture + * does. The console intercepts the name before the tool transport, so it never + * reaches `/executions` — a "you can stop waiting now" is not an execution and + * must not be metered, approved or logged as one. + */ +export const ARTIFACT_RENDERED_TOOL = "executor-artifact-rendered"; + +/** + * The host-context key by which a host asks to be told. + * + * Carried on `McpUiHostContext`'s open index signature, the spec's own + * forward-compatibility seam — the same door `PREVIEW_CAPTURE_HOST_CONTEXT_KEY` + * goes through. Only the console sets it, because only the console holds a + * loading surface it needs to take down. Every real MCP client omits it and the + * shell never makes the call, so no host is sent a tool name it has never heard + * of. + * + * A separate key from the preview capture rather than one "this is the console" + * flag: they are two independent capabilities, and a host that wants a seamless + * handoff without storing thumbnails should be able to say exactly that. + */ +export const RENDER_SIGNAL_HOST_CONTEXT_KEY = "executorRenderSignal"; diff --git a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx new file mode 100644 index 0000000000..c9cfbe52bc --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx @@ -0,0 +1,1098 @@ +// `virtual:executor-inner-renderer` is supplied by `innerRendererPlugin` +// (`@executor-js/mcp-apps-shell/vite`). Referenced explicitly — and first, as +// triple-slash directives are only honored above the imports — so the ambient +// declaration travels with this file into consumers that bundle the shell; +// a consumer's tsconfig `include` covers only its own sources. +/// +import "./globals.css"; +import "@tailwindcss/browser"; + +import React, { useState, useEffect, useRef, useCallback, type ReactNode } from "react"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + elicitationDefaultContent, + useElicitationApproval, +} from "@executor-js/react/components/elicitation-approval"; + +import { + createToolCaller, + type ToolCallHost, + type TrustedInteraction, + type TrustedInteractionResponse, +} from "./proxy"; +import * as Components from "./components"; +import { PREVIEW_CAPTURE_HOST_CONTEXT_KEY, SAVE_ARTIFACT_PREVIEW_TOOL } from "./preview-capture"; +import { ARTIFACT_RENDERED_TOOL, RENDER_SIGNAL_HOST_CONTEXT_KEY } from "./render-signal"; +import { isFillDisplayMode } from "./display-mode"; +import innerRendererScript from "virtual:executor-inner-renderer"; +// Raw source, not a compiled stylesheet: the inner frame compiles utilities on +// demand against these tokens. See `buildRendererSrcDoc`. +import themeSource from "./theme.css?raw"; +// The in-frame Tailwind compiler, as text to inline under a CSP that forbids +// fetching anything. Supplied by `tailwindBrowserSourcePlugin` — the package +// exports only its module entry, and this needs the bytes. +import tailwindBrowserScript from "virtual:executor-tailwind-browser"; + +type PendingInteraction = TrustedInteraction & { + resolve: (response: TrustedInteractionResponse) => void; + /** Where a "don't ask again" for this pause would be stored, or `null` when + * the pause cannot be scoped to a tool and artifact — see + * `approvalGrantKey`. `null` hides the option rather than storing a grant + * that would match the wrong thing. */ + grantKey: string | null; +}; + +export type McpAppsShellHost = ToolCallHost & { + readonly getHostContext: () => McpUiHostContext | undefined; + readonly openLink: (params: { url: string }) => Promise; + ontoolinput?: (params: { arguments?: Record }) => void; + ontoolresult?: (result: CallToolResult) => void; + onerror?: (err: Error) => void; + onhostcontextchanged?: (ctx: McpUiHostContext) => void; +}; + +type RendererState = { + token: string; + code: string; + srcDoc: string; + config: Record; + height: number; +}; + +type RendererRequest = + | { + type: "executor.toolCall"; + requestId: number; + token: string; + path: unknown; + args: unknown; + role?: unknown; + } + | { type: "executor.renderer.ready"; token: string } + | { type: "executor.renderer.config"; token: string; config: unknown } + | { type: "executor.renderer.size"; token: string; height: unknown } + | { type: "executor.renderer.error"; token: string; message: unknown } + | { type: "executor.renderer.preview"; token: string; preview: unknown } + /** The inner frame has committed and painted a tree — see `RenderSignal` in + * `./inner-renderer`. Relayed to the host as `ARTIFACT_RENDERED_TOOL`. */ + | { type: "executor.renderer.rendered"; token: string }; + +/** + * Whether this host wants a preview snapshot captured. + * + * Advertised by the console through `McpUiHostContext`'s open index signature, + * which is the spec's own forward-compatibility seam. Absent everywhere else — + * a real MCP client has nowhere to put a preview — and absent means the inner + * frame never captures anything, so the cost is not paid where it cannot pay + * off. + */ +const hostContextAllowsPreviewCapture = (ctx: McpUiHostContext | undefined): boolean => + ctx?.[PREVIEW_CAPTURE_HOST_CONTEXT_KEY] === true; + +/** + * Whether this host wants to be told when the artifact is on screen. + * + * Only a host that holds its own loading surface across the open does — the + * console — and it says so on the same open index signature the preview + * capability travels on. Absent everywhere else, and absent means the call is + * never made, so no MCP client is sent a tool name it has never heard of. + */ +const hostContextWantsRenderSignal = (ctx: McpUiHostContext | undefined): boolean => + ctx?.[RENDER_SIGNAL_HOST_CONTEXT_KEY] === true; + +// --------------------------------------------------------------------------- +// Theme application from MCP Apps host context +// --------------------------------------------------------------------------- + +function applyTheme(ctx: McpUiHostContext) { + if (ctx.theme) { + document.documentElement.classList.toggle("dark", ctx.theme === "dark"); + } +} + +/** + * The CSS hook for a bounded viewport. See the `html.artifact-fill` rule in + * `theme.css` for what it does and why it cannot be unconditional. + * + * Applied to the shell's own document here, and to the inner frame's document by + * the inner renderer when the shell tells it the mode — the chain has to hold at + * every link or the artifact's `h-full` still resolves against `auto`. + */ +const FILL_DOCUMENT_CLASS = "artifact-fill"; + +const applyFillMode = (ctx: McpUiHostContext | undefined) => { + document.documentElement.classList.toggle(FILL_DOCUMENT_CLASS, isFillDisplayMode(ctx)); +}; + +/** + * How tall the shell may grow before it scrolls internally, or `undefined` for + * "as tall as the content is". + * + * Precedence, tightest first: + * + * 1. The generated component's own `config.maxHeight` — the author asked for a + * scroll box, so give them one. + * 2. The host's `containerDimensions`, the MCP-Apps way of saying "this is the + * room you have". + * 3. Otherwise NO cap. An app cannot know how much room its host has; the + * protocol's answer is for the app to report its true content height via + * `size-changed` and let the host size the frame. Capping here would also be + * circular for a host that does exactly that — the shell's own viewport IS + * the frame the host is trying to size, so measuring it would pin the frame + * to whatever height it already had. + * + * The old unconditional 800px default was case 3 done wrong: it silently clipped + * anything taller, in every host, including ones with room to spare. + * + * ## This function is not consulted in fill mode + * + * A max height is a ceiling on GROWTH, which is a concept that only means + * something when the frame grows — that is, inline. In fill mode the shell is + * already exactly the host's viewport, so there is nothing left to cap: the + * layout is `height: 100%` all the way down and the artifact scrolls inside + * itself. + * + * That includes `config.maxHeight`. A component asking for a 600px scroll box is + * describing how it wants to sit inside a taller document; on a page where it IS + * the document, honoring it would letterbox the artifact into the top 600px of + * an otherwise empty screen. So in fill mode it is deliberately ignored, and it + * keeps its exact inline meaning everywhere else. + */ +const resolveMaxHeight = ( + config: Record, + hostContext: McpUiHostContext | undefined, +): number | undefined => { + if (isFillDisplayMode(hostContext)) return undefined; + + if (typeof config.maxHeight === "number") return config.maxHeight; + + const container = hostContext?.containerDimensions; + if (container) { + if ("height" in container && typeof container.height === "number") return container.height; + if ("maxHeight" in container && typeof container.maxHeight === "number") { + return container.maxHeight; + } + } + + return undefined; +}; + +const createRendererToken = (): string => { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `renderer_${Date.now()}_${Math.random().toString(36).slice(2)}`; +}; + +const escapeInlineHtml = (value: string): string => + value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +const escapeStyleContent = (value: string): string => value.replace(/<\/style/gi, "<\\/style"); + +const escapeScriptContent = (value: string): string => value.replace(/<\/script/gi, "<\\/script"); + +const collectShellCss = (): string => + Array.from(document.styleSheets) + .map((sheet) => { + try { + return Array.from(sheet.cssRules) + .map((rule) => rule.cssText) + .join("\n"); + } catch { + return ""; + } + }) + .filter((css) => css.length > 0) + .join("\n"); + +/** + * The srcDoc the model's component renders into. + * + * It carries TWO stylesheets, and the difference between them is the whole + * reason artifacts used to look half-styled: + * + * - `collectShellCss()` is the shell's own COMPILED stylesheet, scraped out of + * the live document. It has the `@font-face` rules with their `data:` URLs + * already resolved (the build did that), plus every utility executor's own + * components happen to use. It cannot contain anything else, because + * Tailwind only emits utilities it saw in a source file at build time — and + * the model's code did not exist then. + * + * - `themeSource` + the in-frame compiler is what makes the model's OWN + * classes real. `text-2xl`, `md:grid-cols-4`, `tracking-[0.08em]` — none of + * those appear in executor's components, so none of them were in the + * compiled sheet, and they silently did nothing: `text-2xl` computed to + * 16px, a responsive grid stayed one column. The frame therefore gets the + * theme as SOURCE, in the `text/tailwindcss` style tag `@tailwindcss/browser` + * looks for, and compiles utilities on demand against executor's tokens. + * + * The compiler is inlined rather than fetched because the CSP here is + * `default-src 'none'` with no `connect-src` — deliberately, since artifact code + * is untrusted. Nothing in this frame may open a connection, so everything it + * needs travels with it. + */ +const buildRendererSrcDoc = (token: string): string => { + const css = collectShellCss(); + return ` + + + + + + + + + + +
+ + +`; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +// --------------------------------------------------------------------------- +// Remembered approvals ("Approve and don't ask again") +// --------------------------------------------------------------------------- + +/** + * A grant is scoped to ONE tool address, on ONE artifact, for ONE viewer. + * + * - Tool address: `interaction.address`, the only stable identity a pause + * carries (the execution id is minted per call). Approving + * `github.issues.create` here says nothing about `github.repos.delete`. + * - Artifact: an artifact's bindings are what turn that address into a real + * connection, so the same address on a different artifact is a different + * action and asks again. + * - Viewer: v1 scoping is the storage itself. The shell frame is same-origin + * with the console (see the outer sandbox in `artifact-renderer.tsx`), so + * these grants live in the console origin's `localStorage`, which is already + * per browser profile and therefore per signed-in person at this machine. + * There is no viewer id inside the shell frame and inventing one would be a + * fiction; when grants need to follow a viewer across devices they belong on + * the server, keyed by the real viewer id. + * + * Nothing here is a security boundary. The SERVER still gates every call; this + * only decides whether the shell shows the human a modal it would otherwise + * answer the same way. + */ +const APPROVAL_GRANTS_STORAGE_KEY = "executor.shell.approval-grants.v1"; + +/** + * `localStorage`, or `null` when it is unusable. + * + * The shell document is same-origin with the console and normally has storage, + * but that is a property of how it is embedded, not a guarantee: an opaque + * origin throws on the property read itself, and a browser with site data + * blocked throws on write while still exposing the object. Both are probed + * here, once per call, so an approval can never be broken by storage — the + * worst case is that a grant is not remembered. + */ +const approvalGrantStorage = (): Storage | null => { + try { + const storage = globalThis.localStorage; + const probe = `${APPROVAL_GRANTS_STORAGE_KEY}.probe`; + storage.setItem(probe, "1"); + storage.removeItem(probe); + return storage; + } catch { + return null; + } +}; + +const readApprovalGrants = (): ReadonlySet => { + const storage = approvalGrantStorage(); + if (!storage) return new Set(); + try { + const raw = storage.getItem(APPROVAL_GRANTS_STORAGE_KEY); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + return new Set( + Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === "string") + : [], + ); + } catch { + return new Set(); + } +}; + +const rememberApprovalGrant = (key: string): void => { + const storage = approvalGrantStorage(); + if (!storage) return; + const next = new Set(readApprovalGrants()); + next.add(key); + try { + storage.setItem(APPROVAL_GRANTS_STORAGE_KEY, JSON.stringify([...next])); + } catch { + // Quota, or storage revoked between the probe and the write. The approval + // the user just gave still stands; only the memory of it is lost. + } +}; + +/** + * `null` when this pause cannot be scoped — no artifact id, or a pause with no + * address on it. Such an interaction is never auto-approved and never offers to + * be remembered, because a grant we cannot scope is a grant we cannot honor + * precisely. + */ +const approvalGrantKey = (artifactId: string | undefined, address: unknown): string | null => + artifactId !== undefined && + artifactId.length > 0 && + typeof address === "string" && + address.length > 0 + ? // A space separates the two halves: artifact ids are `art_`-prefixed + // base36 and tool addresses are dot-joined identifiers, so neither can + // contain one and no pair can collide. + `${artifactId} ${address}` + : null; + +// --------------------------------------------------------------------------- +// Shell App — connects to MCP host, receives code, renders components +// --------------------------------------------------------------------------- + +export function McpAppsShell({ + app, + initialCode, +}: { + app: McpAppsShellHost; + initialCode?: string | undefined; +}) { + const [component, setComponent] = useState(null); + const [renderer, setRenderer] = useState(null); + const [error, setError] = useState(null); + const [hostContext, setHostContext] = useState(); + const [pendingInteraction, setPendingInteraction] = useState(null); + const callToolRef = useRef< + (path: readonly string[], args: readonly unknown[], role?: string) => Promise + >(() => Promise.resolve(null)); + // Which artifact is rendering. Set from the tool input the host delivers + // alongside the code, so the server can look up this artifact's connection + // bindings when the component calls a tool. Held in a ref rather than state + // because only the tool caller reads it, and rebuilding the caller on every + // delivery would race the first call. + const artifactIdRef = useRef(undefined); + const pendingInteractionRef = useRef(null); + const rendererFrameRef = useRef(null); + const rendererRef = useRef(null); + // Whether the embedding host can store a preview snapshot. Held in a ref + // because the renderer message handler is built once and must not be rebuilt + // when the context changes. + const capturePreviewRef = useRef(false); + capturePreviewRef.current = hostContextAllowsPreviewCapture(hostContext); + // Whether the host has bounded this shell. Held in a ref for the same reason + // as the capture flag: the renderer message handler is built once, and reading + // this from the closure would pin whichever mode was current when it was made. + const fillRef = useRef(false); + fillRef.current = isFillDisplayMode(hostContext); + // Hand a captured snapshot to the host. Swallows every failure: a preview is + // a picture for a list, and the artifact the user is looking at does not + // depend on it in any way. + const savePreviewRef = useRef<(artifactId: string, preview: string) => void>(() => {}); + savePreviewRef.current = (artifactId, preview) => { + void Promise.resolve( + app.callServerTool({ + name: SAVE_ARTIFACT_PREVIEW_TOOL, + arguments: { artifactId, preview }, + }), + ).then( + () => undefined, + () => undefined, + ); + }; + // Whether the host asked to be told when the artifact paints. A ref for the + // same reason as the two above: the renderer message handler is built once, + // and reading this from the closure would pin whichever context was current + // when it was made. + const wantsRenderSignalRef = useRef(false); + wantsRenderSignalRef.current = hostContextWantsRenderSignal(hostContext); + /** + * Tell the host the artifact is on screen. At most once per shell. + * + * Once-only because the host's question is "may I take my loading surface + * down", which is answered exactly once; a re-render inside the artifact is + * not a second answer. The latch lives here rather than in the inner frame so + * it survives that frame remounting. + * + * ## The fact outlives the capability to report it + * + * "The artifact painted" and "the host wants to know" arrive over two + * INDEPENDENT channels, and their order is not guaranteed: the paint comes up + * from the inner frame, while the capability comes down on the host context — + * which on a fast host lands before the first render and on a slow one arrives + * after it. Dropping the signal because the context had not arrived yet leaves + * the host holding its loading surface over a fully rendered artifact, + * forever, since the paint never happens twice. + * + * So the paint is REMEMBERED rather than tested against the capability at the + * moment it occurs. Whichever of the two arrives second sends the call. + * + * Swallows every failure, like the preview relay: a host that does not + * implement the name rejects the call, and the correct response to that is + * nothing at all. + */ + const renderSignalSentRef = useRef(false); + /** The artifact has painted, whether or not anyone could be told yet. */ + const renderedRef = useRef(false); + const signalRenderedRef = useRef<() => void>(() => {}); + signalRenderedRef.current = () => { + renderedRef.current = true; + if (renderSignalSentRef.current || !wantsRenderSignalRef.current) return; + renderSignalSentRef.current = true; + void Promise.resolve( + app.callServerTool({ + name: ARTIFACT_RENDERED_TOOL, + ...(artifactIdRef.current === undefined + ? {} + : { arguments: { artifactId: artifactIdRef.current } }), + }), + ).then( + () => undefined, + () => undefined, + ); + }; + + // The other order: the artifact painted before the host said it wanted to + // know. Runs on every host-context change, and the latch inside makes all but + // the first a no-op. + useEffect(() => { + if (renderedRef.current) signalRenderedRef.current(); + }, [hostContext]); + + useEffect(() => { + rendererRef.current = renderer; + }, [renderer]); + + const requestTrustedInteraction = useCallback( + (interaction: TrustedInteraction): Promise => + new Promise((resolve) => { + // Remembered approvals are answered HERE, before any state is set, so a + // granted tool never mounts the modal — not even for a frame. + const grantKey = approvalGrantKey(artifactIdRef.current, interaction.interaction.address); + if (grantKey !== null && readApprovalGrants().has(grantKey)) { + // A schema-bearing elicitation is only auto-answerable when its own + // defaults satisfy it. A required field with no default has no + // answer the user ever gave, so fall through to the modal rather + // than resuming with fabricated values. + const content = elicitationDefaultContent(interaction.interaction.requestedSchema); + if (content !== null) { + resolve({ action: "accept", content }); + return; + } + } + + if (pendingInteractionRef.current) { + resolve({ action: "cancel" }); + return; + } + + const pending = { ...interaction, resolve, grantKey }; + pendingInteractionRef.current = pending; + setPendingInteraction(pending); + }), + [], + ); + + const completeTrustedInteraction = useCallback((response: TrustedInteractionResponse) => { + const pending = pendingInteractionRef.current; + pendingInteractionRef.current = null; + setPendingInteraction(null); + pending?.resolve(response); + }, []); + + const postToRenderer = useCallback((message: Record) => { + const current = rendererRef.current; + const target = rendererFrameRef.current?.contentWindow; + if (!current || !target) return; + target.postMessage({ ...message, token: current.token }, "*"); + }, []); + + useEffect(() => { + const handleRendererMessage = (event: MessageEvent) => { + const current = rendererRef.current; + if (!current || event.source !== rendererFrameRef.current?.contentWindow) return; + const data = event.data; + if (!isRecord(data) || data.token !== current.token) return; + const source = event.source; + if (!source || typeof source.postMessage !== "function") return; + const respond = (requestId: number, ok: boolean, value?: unknown, error?: string) => { + source.postMessage( + { + type: "executor.response", + requestId, + token: current.token, + ok, + value, + error, + }, + "*", + ); + }; + + if (data.type === "executor.renderer.ready") { + postToRenderer({ + type: "executor.render", + code: current.code, + theme: hostContext?.theme, + // The inner frame is the last link in the height chain, and it cannot + // see the host context — so the mode is told to it, the same way the + // theme and the preview capability already are. + fill: fillRef.current, + // Only the console advertises somewhere to put a preview. Passing the + // capability down rather than letting the frame guess keeps the inner + // renderer host-agnostic: under a real MCP client this is absent and + // no capture work happens at all. + capturePreview: capturePreviewRef.current, + }); + return; + } + + if (data.type === "executor.renderer.config") { + setRenderer((prev) => + prev && prev.token === current.token + ? { ...prev, config: isRecord(data.config) ? data.config : {} } + : prev, + ); + return; + } + + if (data.type === "executor.renderer.size") { + const height = typeof data.height === "number" ? Math.ceil(data.height) : current.height; + setRenderer((prev) => + prev && prev.token === current.token + ? { ...prev, height: Math.max(120, Math.min(4000, height)) } + : prev, + ); + return; + } + + if (data.type === "executor.renderer.rendered") { + signalRenderedRef.current(); + return; + } + + if (data.type === "executor.renderer.error") { + if (typeof data.message === "string") { + console.error("[executor-shell] Renderer error:", data.message); + } + // The frame rendered an error surface, which is still something for the + // user to look at — `RenderSignal` wraps the error path too, so the + // `rendered` message is already on its way. Nothing extra to do here. + return; + } + + if (data.type === "executor.renderer.preview") { + // Relay outward and forget. The picture is a nicety: nothing here waits + // on it, retries it, or reports its failure to the user. It travels + // over the same `callServerTool` seam as every other host call rather + // than a bespoke channel, and only to a host that asked for it. + // + // Through a ref for the same reason the tool caller is: this handler is + // built once, and reading `app` from the closure would pin whichever + // host object existed when it was created. + const artifactId = artifactIdRef.current; + if ( + capturePreviewRef.current && + artifactId !== undefined && + typeof data.preview === "string" && + data.preview !== "" + ) { + savePreviewRef.current(artifactId, data.preview); + } + return; + } + + // The ONLY request the generated iframe may make. There is no code + // channel: the inner renderer sends a tool path plus args, and the outer + // frame is what turns that into the one `execute-action` grammar. + if (data.type === "executor.toolCall") { + if (!Array.isArray(data.path)) { + respond(data.requestId, false, undefined, "Invalid tool path."); + return; + } + callToolRef + .current( + data.path as readonly string[], + Array.isArray(data.args) ? data.args : [], + typeof data.role === "string" && data.role.length > 0 ? data.role : undefined, + ) + .then((value) => respond(data.requestId, true, value)) + .catch((err: unknown) => + respond( + data.requestId, + false, + undefined, + err instanceof Error ? err.message : String(err), + ), + ); + } + }; + + window.addEventListener("message", handleRendererMessage); + return () => window.removeEventListener("message", handleRendererMessage); + }, [hostContext?.theme, postToRenderer]); + + useEffect(() => { + if (renderer) { + postToRenderer({ type: "executor.theme", theme: hostContext?.theme }); + } + }, [hostContext?.theme, postToRenderer, renderer]); + + /** + * Whether the host has given this shell a bounded viewport to fill. + * + * The shell does not decide this and measures nothing to find out — the host + * owns the mode and says so in its context. That is what lets the inner + * renderer keep reporting its size unconditionally in every mode: a fill-mode + * host simply does not consume the report. + */ + const fill = isFillDisplayMode(hostContext); + + // Relay a mode change to an already-rendered frame. `executor.render` carries + // the mode for a frame that has not booted yet; this covers the frame that is + // already up when the host changes its mind — which is not hypothetical, since + // the console's very first context arrives before it has measured its + // container and switches to fill once it has. + useEffect(() => { + if (renderer) { + postToRenderer({ type: "executor.fill", fill }); + } + }, [fill, postToRenderer, renderer]); + + /** Render a JSX code string in the sandboxed inner iframe. */ + const renderCode = useCallback((code: string) => { + try { + const token = createRendererToken(); + const nextRenderer = { + token, + code, + srcDoc: buildRendererSrcDoc(token), + config: {}, + height: 240, + }; + rendererRef.current = nextRenderer; + setRenderer(nextRenderer); + setComponent(null); + setError(null); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(`Compilation error: ${msg}`); + setComponent(null); + rendererRef.current = null; + setRenderer(null); + } + }, []); + + useEffect(() => { + callToolRef.current = createToolCaller( + app, + requestTrustedInteraction, + () => artifactIdRef.current, + ); + + // Handle tool input — fires on init (including page reload) with + // the tool arguments. For generative UI the arguments contain { code }. + app.ontoolinput = (params: { arguments?: Record }) => { + const artifactId = params.arguments?.artifactId; + if (typeof artifactId === "string") artifactIdRef.current = artifactId; + const code = params.arguments?.code; + if (code && typeof code === "string") { + renderCode(code); + } + }; + + app.ontoolresult = (result: CallToolResult) => { + const structured = result.structuredContent as Record | undefined; + const code = structured?.code; + // The result is where `create-artifact` / `show-artifact` report the id + // they saved under; the input only carries one on a reload replay. + const artifactId = structured?.artifactId; + if (typeof artifactId === "string") artifactIdRef.current = artifactId; + + if (code && typeof code === "string") { + renderCode(code); + return; + } + + // Not a generative UI result — render a data view + const DataView = () => { + const text = result.content?.find((c) => c.type === "text")?.text; + const isError = (result as { isError?: boolean }).isError; + const data = structured as Record | undefined; + + return ( + + + {isError ? ( + + + Error + + {text ?? "Unknown error"} + + + ) : ( +
+                  {data ? JSON.stringify(data, null, 2) : (text ?? "(no result)")}
+                
+ )} +
+
+ ); + }; + setComponent(() => DataView); + rendererRef.current = null; + setRenderer(null); + setError(null); + }; + + app.onerror = (err) => { + console.error("[executor-shell] App error:", err); + }; + + app.onhostcontextchanged = (ctx: McpUiHostContext) => { + // A host-context change notification carries only the CHANGED fields, so + // the merged context — not the delta — is what the mode must be read + // from: a resize sends `containerDimensions` alone, with no `displayMode` + // beside it, and judging the delta would read that as "no longer fill". + setHostContext((prev) => { + const next = { ...prev, ...ctx }; + applyFillMode(next); + return next; + }); + applyTheme(ctx); + }; + + (app as { onteardown?: () => Promise> }).onteardown = async () => { + return {}; + }; + }, [app, renderCode, requestTrustedInteraction]); + + // Apply initial host context + useEffect(() => { + const ctx = app.getHostContext(); + if (ctx) { + setHostContext(ctx); + applyTheme(ctx); + applyFillMode(ctx); + } + }, [app]); + + useEffect(() => { + if (initialCode) renderCode(initialCode); + }, [initialCode, renderCode]); + + /** + * The two surfaces that never reach the inner frame still end the wait. + * + * `error` is a shell-level compile failure and `component` is the data view a + * non-generative tool result renders — neither creates a renderer frame, so + * neither can emit `executor.renderer.rendered`. Without this, a host holding + * a loading surface would hold it forever on exactly the paths where there is + * nothing more coming. + * + * The signal itself is latched once, so this and the frame's own message + * cannot both take effect. + */ + useEffect(() => { + if (error !== null || component !== null) signalRenderedRef.current(); + }, [component, error]); + + if (error) { + return ( +
+ +
+ ); + } + + if (!component && !renderer) { + /* + A host that holds its own loading surface gets NOTHING here. + + The console does: it shows one skeleton from navigation until the + `rendered` signal, and this card would be a second loading state + underneath the first — briefly visible at every edge the cover does not + reach, and drawn for a host that explicitly said it is already handling + the wait. Asking for the signal is exactly the statement "I am showing the + user something; do not show them something else." + + Every other host — every real MCP client — still gets the card, because + there a bare empty frame while the shell boots is worse than a placeholder. + */ + if (wantsRenderSignalRef.current) return null; + + return ( +
+ +
+ ); + } + + const Component = component; + const config = renderer?.config ?? {}; + const maxHeight = resolveMaxHeight(config, hostContext); + const rendererHeight = renderer + ? maxHeight === undefined + ? renderer.height + : Math.min(renderer.height, maxHeight) + : undefined; + + return ( + +
+ {renderer ? ( +