13 scenarios driving the real API + MCP surface: CRUD + scope visibility, cross-user isolation, read-mode (GET vs write), per-toolkit + inherit-org policies, wildcard auto-include, fail-closed selectors, the core-tool scope boundary (scoped connections.list/integrations.list return only in-slice; scoped sessions can't create connections/policies), and the URL-routed console flow (create, deep-link, browser back).
Unified · Split
| @@ -337,6 +337,7 @@ · 336 unmodified lines | ||
| 337 | 337 | "@executor-js/plugin-graphql": "workspace:*", |
| 338 | 338 | "@executor-js/plugin-mcp": "workspace:*", |
| 339 | 339 | "@executor-js/plugin-openapi": "workspace:*", |
| 340 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 340 | 341 | "@executor-js/sdk": "workspace:*", |
| 341 | 342 | "@kitlangton/terminal-control": "^0.3.0", |
| 342 | 343 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -26,6 +26,7 @@ · 25 unmodified lines | ||
| 26 | 26 | "@executor-js/plugin-graphql": "workspace:*", |
| 27 | 27 | "@executor-js/plugin-mcp": "workspace:*", |
| 28 | 28 | "@executor-js/plugin-openapi": "workspace:*", |
| 29 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 29 | 30 | "@executor-js/sdk": "workspace:*", |
| 30 | 31 | "@kitlangton/terminal-control": "^0.3.0", |
| 31 | 32 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -0,0 +1,370 @@ | ||
| 1 | +// Selfhost · toolkit scope must narrow built-in core tools, not only dynamic | |
| 2 | +// connection tools. A scoped MCP session calling `connections.list` / | |
| 3 | +// `integrations.list` must see ONLY in-slice catalog rows; guessed out-of-slice | |
| 4 | +// dynamic addresses are blocked at execute; per-toolkit `require_approval` | |
| 5 | +// tightens the matching tool under that toolkit only. | |
| 6 | +import { randomBytes } from "node:crypto"; | |
| 7 | + | |
| 8 | +import { expect } from "@effect/vitest"; | |
| 9 | +import { Effect } from "effect"; | |
| 10 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 11 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 12 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 13 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 14 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 15 | + | |
| 16 | +import { scenario } from "../src/scenario"; | |
| 17 | +import { Api, Mcp, Target } from "../src/services"; | |
| 18 | +import type { McpSession } from "../src/surfaces/mcp"; | |
| 19 | + | |
| 20 | +const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const); | |
| 21 | +const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 22 | + | |
| 23 | +const isBlocked = (text: string): boolean => text.includes("tool_blocked"); | |
| 24 | + | |
| 25 | +const isSuccessfulGreeting = (text: string): boolean => text.includes("Hello from greeting MCP"); | |
| 26 | + | |
| 27 | +const listConnections = (session: McpSession) => | |
| 28 | + session.call("execute", { | |
| 29 | + code: ` | |
| 30 | +const list = await tools.executor.coreTools.connections.list({}); | |
| 31 | +return JSON.stringify(list.ok ? list.data : { error: list.error }); | |
| 32 | +`, | |
| 33 | + }); | |
| 34 | + | |
| 35 | +const listIntegrations = (session: McpSession) => | |
| 36 | + session.call("execute", { | |
| 37 | + code: ` | |
| 38 | +const list = await tools.executor.coreTools.integrations.list({}); | |
| 39 | +return JSON.stringify(list.ok ? list.data : { error: list.error }); | |
| 40 | +`, | |
| 41 | + }); | |
| 42 | + | |
| 43 | +scenario( | |
| 44 | + "Toolkits · scoped MCP sessions narrow built-in connections.list and integrations.list", | |
| 45 | + { timeout: 180_000 }, | |
| 46 | + Effect.scoped( | |
| 47 | + Effect.gen(function* () { | |
| 48 | + const target = yield* Target; | |
| 49 | + const { client: makeApiClient } = yield* Api; | |
| 50 | + const mcp = yield* Mcp; | |
| 51 | + const identity = yield* target.newIdentity(); | |
| 52 | + const client = yield* makeApiClient(api, identity); | |
| 53 | + | |
| 54 | + const addConnection = (slug: string, conn: string) => | |
| 55 | + Effect.gen(function* () { | |
| 56 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 57 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 58 | + auth: { | |
| 59 | + validateAuthorization: (authorization) => | |
| 60 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 61 | + }, | |
| 62 | + }); | |
| 63 | + yield* client.mcp.addServer({ | |
| 64 | + payload: { | |
| 65 | + transport: "remote", | |
| 66 | + name: `Greeting ${slug}`, | |
| 67 | + endpoint: server.endpoint, | |
| 68 | + slug, | |
| 69 | + authenticationTemplate: [ | |
| 70 | + { | |
| 71 | + type: "apiKey", | |
| 72 | + headers: { | |
| 73 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 74 | + }, | |
| 75 | + }, | |
| 76 | + ], | |
| 77 | + }, | |
| 78 | + }); | |
| 79 | + yield* client.connections.create({ | |
| 80 | + payload: { | |
| 81 | + owner: "org", | |
| 82 | + name: ConnectionName.make(conn), | |
| 83 | + integration: IntegrationSlug.make(slug), | |
| 84 | + template: AuthTemplateSlug.make("header"), | |
| 85 | + value: token, | |
| 86 | + }, | |
| 87 | + }); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + const slugIn = ident("corein"); | |
| 91 | + const slugOut = ident("coreout"); | |
| 92 | + const connIn = ident("conn"); | |
| 93 | + const connOut = ident("conn"); | |
| 94 | + yield* addConnection(slugIn, connIn); | |
| 95 | + yield* addConnection(slugOut, connOut); | |
| 96 | + | |
| 97 | + const kit = yield* client.toolkits.create({ | |
| 98 | + payload: { | |
| 99 | + slug: ident("corekit"), | |
| 100 | + name: "Core scoped kit", | |
| 101 | + scope: "workspace", | |
| 102 | + connections: [ | |
| 103 | + { | |
| 104 | + integration: IntegrationSlug.make(slugIn), | |
| 105 | + connection: connIn, | |
| 106 | + access: "full", | |
| 107 | + }, | |
| 108 | + ], | |
| 109 | + }, | |
| 110 | + }); | |
| 111 | + | |
| 112 | + const scoped = mcp.session(identity, { toolkit: kit.slug }); | |
| 113 | + const bare = mcp.session(identity); | |
| 114 | + | |
| 115 | + const scopedConnsResult = yield* listConnections(scoped); | |
| 116 | + const bareConnsResult = yield* listConnections(bare); | |
| 117 | + expect( | |
| 118 | + scopedConnsResult.ok, | |
| 119 | + `scoped connections.list must succeed; text=${scopedConnsResult.text}`, | |
| 120 | + ).toBe(true); | |
| 121 | + | |
| 122 | + const scopedConns = JSON.parse(scopedConnsResult.text) as { | |
| 123 | + connections: ReadonlyArray<{ integration: string; name: string }>; | |
| 124 | + }; | |
| 125 | + const bareConns = JSON.parse(bareConnsResult.text) as { | |
| 126 | + connections: ReadonlyArray<{ integration: string; name: string }>; | |
| 127 | + }; | |
| 128 | + | |
| 129 | + expect( | |
| 130 | + scopedConns.connections.map((c) => `${c.integration}/${c.name}`), | |
| 131 | + "scoped connections.list shows only the in-slice connection", | |
| 132 | + ).toEqual([`${slugIn}/${connIn}`]); | |
| 133 | + expect( | |
| 134 | + bareConns.connections.map((c) => `${c.integration}/${c.name}`), | |
| 135 | + "bare connections.list still sees both connections", | |
| 136 | + ).toEqual(expect.arrayContaining([`${slugIn}/${connIn}`, `${slugOut}/${connOut}`])); | |
| 137 | + | |
| 138 | + const scopedIntsResult = yield* listIntegrations(scoped); | |
| 139 | + const bareIntsResult = yield* listIntegrations(bare); | |
| 140 | + expect( | |
| 141 | + scopedIntsResult.ok, | |
| 142 | + `scoped integrations.list must succeed; text=${scopedIntsResult.text}`, | |
| 143 | + ).toBe(true); | |
| 144 | + | |
| 145 | + const scopedInts = JSON.parse(scopedIntsResult.text) as { | |
| 146 | + integrations: ReadonlyArray<{ slug: string }>; | |
| 147 | + }; | |
| 148 | + const bareInts = JSON.parse(bareIntsResult.text) as { | |
| 149 | + integrations: ReadonlyArray<{ slug: string }>; | |
| 150 | + }; | |
| 151 | + | |
| 152 | + const scopedSlugs = scopedInts.integrations.map((i) => i.slug); | |
| 153 | + expect(scopedSlugs, "scoped integrations.list includes in-slice slug").toContain(slugIn); | |
| 154 | + expect(scopedSlugs, "scoped integrations.list omits out-of-slice slug").not.toContain( | |
| 155 | + slugOut, | |
| 156 | + ); | |
| 157 | + | |
| 158 | + const bareSlugs = bareInts.integrations.map((i) => i.slug); | |
| 159 | + expect(bareSlugs, "bare integrations.list still sees both integrations").toEqual( | |
| 160 | + expect.arrayContaining([slugIn, slugOut]), | |
| 161 | + ); | |
| 162 | + | |
| 163 | + const inSlice = yield* scoped.call("execute", { | |
| 164 | + code: `return await tools.${slugIn}.org.${connIn}.simple_echo({});`, | |
| 165 | + }); | |
| 166 | + expect(inSlice.ok, `in-slice tool runs; text=${inSlice.text}`).toBe(true); | |
| 167 | + | |
| 168 | + const outSlice = yield* scoped.call("execute", { | |
| 169 | + code: `return await tools.${slugOut}.org.${connOut}.simple_echo({});`, | |
| 170 | + }); | |
| 171 | + expect( | |
| 172 | + isBlocked(outSlice.text), | |
| 173 | + `guessed out-of-slice address must be tool_blocked; text=${outSlice.text}`, | |
| 174 | + ).toBe(true); | |
| 175 | + expect( | |
| 176 | + isSuccessfulGreeting(outSlice.text), | |
| 177 | + `out-of-slice must not run upstream; text=${outSlice.text}`, | |
| 178 | + ).toBe(false); | |
| 179 | + expect( | |
| 180 | + outSlice.text.includes("suggestions"), | |
| 181 | + `out-of-slice must not leak tool suggestions; text=${outSlice.text}`, | |
| 182 | + ).toBe(false); | |
| 183 | + }), | |
| 184 | + ), | |
| 185 | +); | |
| 186 | + | |
| 187 | +scenario( | |
| 188 | + "Toolkits · scoped MCP sessions cannot create connections or policies", | |
| 189 | + { timeout: 180_000 }, | |
| 190 | + Effect.scoped( | |
| 191 | + Effect.gen(function* () { | |
| 192 | + const target = yield* Target; | |
| 193 | + const { client: makeApiClient } = yield* Api; | |
| 194 | + const mcp = yield* Mcp; | |
| 195 | + const identity = yield* target.newIdentity(); | |
| 196 | + const client = yield* makeApiClient(api, identity); | |
| 197 | + | |
| 198 | + const slug = ident("adm"); | |
| 199 | + const conn = ident("conn"); | |
| 200 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 201 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 202 | + auth: { | |
| 203 | + validateAuthorization: (authorization) => | |
| 204 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 205 | + }, | |
| 206 | + }); | |
| 207 | + yield* client.mcp.addServer({ | |
| 208 | + payload: { | |
| 209 | + transport: "remote", | |
| 210 | + name: `Greeting ${slug}`, | |
| 211 | + endpoint: server.endpoint, | |
| 212 | + slug, | |
| 213 | + authenticationTemplate: [ | |
| 214 | + { | |
| 215 | + type: "apiKey", | |
| 216 | + headers: { | |
| 217 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 218 | + }, | |
| 219 | + }, | |
| 220 | + ], | |
| 221 | + }, | |
| 222 | + }); | |
| 223 | + yield* client.connections.create({ | |
| 224 | + payload: { | |
| 225 | + owner: "org", | |
| 226 | + name: ConnectionName.make(conn), | |
| 227 | + integration: IntegrationSlug.make(slug), | |
| 228 | + template: AuthTemplateSlug.make("header"), | |
| 229 | + value: token, | |
| 230 | + }, | |
| 231 | + }); | |
| 232 | + | |
| 233 | + const kit = yield* client.toolkits.create({ | |
| 234 | + payload: { | |
| 235 | + slug: ident("admkit"), | |
| 236 | + name: "Admin blocked kit", | |
| 237 | + scope: "workspace", | |
| 238 | + connections: [ | |
| 239 | + { | |
| 240 | + integration: IntegrationSlug.make(slug), | |
| 241 | + connection: conn, | |
| 242 | + access: "full", | |
| 243 | + }, | |
| 244 | + ], | |
| 245 | + }, | |
| 246 | + }); | |
| 247 | + | |
| 248 | + const scoped = mcp.session(identity, { toolkit: kit.slug }); | |
| 249 | + const newConn = ident("newconn"); | |
| 250 | + | |
| 251 | + const createConn = yield* scoped.call("execute", { | |
| 252 | + code: ` | |
| 253 | +const result = await tools.executor.coreTools.connections.create({ | |
| 254 | + owner: "org", | |
| 255 | + integration: "${slug}", | |
| 256 | + name: "${newConn}", | |
| 257 | + template: "header", | |
| 258 | + value: "blocked-token", | |
| 259 | +}); | |
| 260 | +return JSON.stringify(result.ok ? result.data : { error: result.error }); | |
| 261 | +`, | |
| 262 | + }); | |
| 263 | + expect( | |
| 264 | + isBlocked(createConn.text), | |
| 265 | + `scoped connections.create blocked; text=${createConn.text}`, | |
| 266 | + ).toBe(true); | |
| 267 | + | |
| 268 | + const createPolicy = yield* scoped.call("execute", { | |
| 269 | + code: ` | |
| 270 | +const result = await tools.executor.coreTools.policies.create({ | |
| 271 | + owner: "org", | |
| 272 | + pattern: "${slug}.*", | |
| 273 | + action: "block", | |
| 274 | +}); | |
| 275 | +return JSON.stringify(result.ok ? result.data : { error: result.error }); | |
| 276 | +`, | |
| 277 | + }); | |
| 278 | + expect( | |
| 279 | + isBlocked(createPolicy.text), | |
| 280 | + `scoped policies.create blocked; text=${createPolicy.text}`, | |
| 281 | + ).toBe(true); | |
| 282 | + }), | |
| 283 | + ), | |
| 284 | +); | |
| 285 | + | |
| 286 | +scenario( | |
| 287 | + "Toolkits · a per-toolkit require_approval rule pauses only under that toolkit", | |
| 288 | + { timeout: 180_000 }, | |
| 289 | + Effect.scoped( | |
| 290 | + Effect.gen(function* () { | |
| 291 | + const target = yield* Target; | |
| 292 | + const { client: makeApiClient } = yield* Api; | |
| 293 | + const mcp = yield* Mcp; | |
| 294 | + const identity = yield* target.newIdentity(); | |
| 295 | + const client = yield* makeApiClient(api, identity); | |
| 296 | + | |
| 297 | + const slug = ident("appr"); | |
| 298 | + const conn = ident("conn"); | |
| 299 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 300 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 301 | + auth: { | |
| 302 | + validateAuthorization: (authorization) => | |
| 303 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 304 | + }, | |
| 305 | + }); | |
| 306 | + yield* client.mcp.addServer({ | |
| 307 | + payload: { | |
| 308 | + transport: "remote", | |
| 309 | + name: `Greeting ${slug}`, | |
| 310 | + endpoint: server.endpoint, | |
| 311 | + slug, | |
| 312 | + authenticationTemplate: [ | |
| 313 | + { | |
| 314 | + type: "apiKey", | |
| 315 | + headers: { | |
| 316 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 317 | + }, | |
| 318 | + }, | |
| 319 | + ], | |
| 320 | + }, | |
| 321 | + }); | |
| 322 | + yield* client.connections.create({ | |
| 323 | + payload: { | |
| 324 | + owner: "org", | |
| 325 | + name: ConnectionName.make(conn), | |
| 326 | + integration: IntegrationSlug.make(slug), | |
| 327 | + template: AuthTemplateSlug.make("header"), | |
| 328 | + value: token, | |
| 329 | + }, | |
| 330 | + }); | |
| 331 | + | |
| 332 | + const gated = yield* client.toolkits.create({ | |
| 333 | + payload: { | |
| 334 | + slug: ident("kitappr"), | |
| 335 | + name: "Approval gated", | |
| 336 | + scope: "workspace", | |
| 337 | + connections: [ | |
| 338 | + { | |
| 339 | + integration: IntegrationSlug.make(slug), | |
| 340 | + connection: conn, | |
| 341 | + access: "full", | |
| 342 | + }, | |
| 343 | + ], | |
| 344 | + policies: [ | |
| 345 | + { | |
| 346 | + pattern: `${slug}.${conn}.simple_echo`, | |
| 347 | + action: "require_approval", | |
| 348 | + }, | |
| 349 | + ], | |
| 350 | + }, | |
| 351 | + }); | |
| 352 | + | |
| 353 | + const code = `return await tools.${slug}.org.${conn}.simple_echo({});`; | |
| 354 | + | |
| 355 | + const bareRun = yield* mcp.session(identity).call("execute", { code }); | |
| 356 | + expect( | |
| 357 | + bareRun.ok && !bareRun.text.includes("Execution paused"), | |
| 358 | + `bare session runs without toolkit approval gate; text=${bareRun.text}`, | |
| 359 | + ).toBe(true); | |
| 360 | + | |
| 361 | + const gatedRun = yield* mcp | |
| 362 | + .session(identity, { toolkit: gated.slug }) | |
| 363 | + .call("execute", { code }); | |
| 364 | + expect( | |
| 365 | + gatedRun.text, | |
| 366 | + `toolkit require_approval pauses execution; text=${gatedRun.text}`, | |
| 367 | + ).toContain("Execution paused"); | |
| 368 | + }), | |
| 369 | + ), | |
| 370 | +); | |
| @@ -0,0 +1,216 @@ | ||
| 1 | +// Selfhost · cross-user visibility of toolkits. The contract under test: | |
| 2 | +// workspace toolkits ride org-owned plugin storage (visible to every org | |
| 3 | +// member), personal toolkits ride user-owned storage (visible only to their | |
| 4 | +// creating subject), and the MCP toolkit selector is fail-closed — a slug the | |
| 5 | +// caller can't resolve narrows the engine to an EMPTY slice rather than leaking | |
| 6 | +// the slice it names. `resolveScope` returns null for a slug the caller isn't | |
| 7 | +// entitled to (server.ts: "a cross-tenant/personal-of-another-user selector | |
| 8 | +// returns null"), so a hijacked-slug session sees nothing. | |
| 9 | +// | |
| 10 | +// HARNESS CAVEAT (verified in e2e/targets/selfhost.ts): selfhost is | |
| 11 | +// single-tenant today — `newIdentity()` always signs the SAME bootstrap admin | |
| 12 | +// in, so two `newIdentity()` calls yield the SAME subject in the SAME org, not | |
| 13 | +// two distinct members. We assert that fact below so the test self-documents | |
| 14 | +// why the "a DIFFERENT user is denied" leg cannot be driven here. We instead | |
| 15 | +// prove the enforcement MECHANISM that backs cross-user privacy and IS | |
| 16 | +// drivable on one subject: an MCP session whose `?toolkit=` slug does not | |
| 17 | +// resolve for the caller is narrowed to an empty slice and blocks the named | |
| 18 | +// connection's tool at execute. (The true second-member denial belongs to a | |
| 19 | +// multi-tenant target, e.g. cloud, once selfhost grows per-test signup.) | |
| 20 | +import { randomBytes } from "node:crypto"; | |
| 21 | + | |
| 22 | +import { expect } from "@effect/vitest"; | |
| 23 | +import { Effect } from "effect"; | |
| 24 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 25 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 26 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 27 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 28 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 29 | + | |
| 30 | +import { scenario } from "../src/scenario"; | |
| 31 | +import { Api, Mcp, Target } from "../src/services"; | |
| 32 | + | |
| 33 | +const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const); | |
| 34 | +// Identifier-safe (no hyphens) so the sandbox `tools.<int>.<owner>.<conn>.<tool>` | |
| 35 | +// dotted path stays valid JS, and so connection names survive the create-time | |
| 36 | +// normalize (hyphens removed + camelCased) byte-for-byte. | |
| 37 | +const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 38 | + | |
| 39 | +const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string => | |
| 40 | + defs.find((d) => d.name === "execute")?.description ?? ""; | |
| 41 | + | |
| 42 | +scenario( | |
| 43 | + "Toolkits · workspace toolkits are visible to org members; personal toolkits stay subject-private and a non-resolving slug is fail-closed", | |
| 44 | + { timeout: 180_000 }, | |
| 45 | + Effect.scoped( | |
| 46 | + Effect.gen(function* () { | |
| 47 | + const target = yield* Target; | |
| 48 | + const { client: makeApiClient } = yield* Api; | |
| 49 | + const mcp = yield* Mcp; | |
| 50 | + | |
| 51 | + // Two identities + a client each. On selfhost both resolve to the | |
| 52 | + // bootstrap admin (same subject, same org) — see the harness caveat. | |
| 53 | + const idA = yield* target.newIdentity(); | |
| 54 | + const idB = yield* target.newIdentity(); | |
| 55 | + const clientA = yield* makeApiClient(api, idA); | |
| 56 | + const clientB = yield* makeApiClient(api, idB); | |
| 57 | + | |
| 58 | + // Make the single-subject reality explicit: the "different member" leg of | |
| 59 | + // the cross-user contract degenerates here, and the assertions below are | |
| 60 | + // written to what selfhost can actually prove. | |
| 61 | + const sameSubject = idA.credentials?.email === idB.credentials?.email; | |
| 62 | + expect( | |
| 63 | + sameSubject, | |
| 64 | + "selfhost is single-tenant: both identities are the bootstrap admin", | |
| 65 | + ).toBe(true); | |
| 66 | + | |
| 67 | + // Stand up two real greeting MCP servers and register each as an | |
| 68 | + // integration + a connection at the requested owner. `text` is distinct | |
| 69 | + // per connection so a successful execute is unambiguous. | |
| 70 | + const addConnection = (slug: string, conn: string, owner: "org" | "user", text: string) => | |
| 71 | + Effect.gen(function* () { | |
| 72 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 73 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer({ text }), { | |
| 74 | + auth: { | |
| 75 | + validateAuthorization: (authorization) => | |
| 76 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 77 | + }, | |
| 78 | + }); | |
| 79 | + yield* clientA.mcp.addServer({ | |
| 80 | + payload: { | |
| 81 | + transport: "remote", | |
| 82 | + name: `Greeting ${slug}`, | |
| 83 | + endpoint: server.endpoint, | |
| 84 | + slug, | |
| 85 | + authenticationTemplate: [ | |
| 86 | + { | |
| 87 | + type: "apiKey", | |
| 88 | + headers: { | |
| 89 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 90 | + }, | |
| 91 | + }, | |
| 92 | + ], | |
| 93 | + }, | |
| 94 | + }); | |
| 95 | + yield* clientA.connections.create({ | |
| 96 | + payload: { | |
| 97 | + owner, | |
| 98 | + name: ConnectionName.make(conn), | |
| 99 | + integration: IntegrationSlug.make(slug), | |
| 100 | + template: AuthTemplateSlug.make("header"), | |
| 101 | + value: token, | |
| 102 | + }, | |
| 103 | + }); | |
| 104 | + }); | |
| 105 | + | |
| 106 | + // idA seeds ONE org connection and ONE personal connection, and the | |
| 107 | + // expected greeting each tool returns. | |
| 108 | + const orgSlug = ident("orgint"); | |
| 109 | + const orgConn = ident("orgconn"); | |
| 110 | + const orgText = `hello-org-${randomBytes(3).toString("hex")}`; | |
| 111 | + const personalSlug = ident("pvtint"); | |
| 112 | + const personalConn = ident("pvtconn"); | |
| 113 | + const personalText = `hello-pvt-${randomBytes(3).toString("hex")}`; | |
| 114 | + yield* addConnection(orgSlug, orgConn, "org", orgText); | |
| 115 | + yield* addConnection(personalSlug, personalConn, "user", personalText); | |
| 116 | + | |
| 117 | + // A WORKSPACE toolkit (org-owned) over the org connection, and a PERSONAL | |
| 118 | + // toolkit (user-owned) over idA's own connection — both at full access. | |
| 119 | + const workspaceKit = yield* clientA.toolkits.create({ | |
| 120 | + payload: { | |
| 121 | + slug: ident("wskit"), | |
| 122 | + name: "Workspace kit", | |
| 123 | + scope: "workspace", | |
| 124 | + connections: [ | |
| 125 | + { | |
| 126 | + integration: IntegrationSlug.make(orgSlug), | |
| 127 | + connection: orgConn, | |
| 128 | + access: "full", | |
| 129 | + }, | |
| 130 | + ], | |
| 131 | + }, | |
| 132 | + }); | |
| 133 | + const personalKit = yield* clientA.toolkits.create({ | |
| 134 | + payload: { | |
| 135 | + slug: ident("pvtkit"), | |
| 136 | + name: "Personal kit", | |
| 137 | + scope: "personal", | |
| 138 | + connections: [ | |
| 139 | + { | |
| 140 | + integration: IntegrationSlug.make(personalSlug), | |
| 141 | + connection: personalConn, | |
| 142 | + access: "full", | |
| 143 | + }, | |
| 144 | + ], | |
| 145 | + }, | |
| 146 | + }); | |
| 147 | + | |
| 148 | + // (a) The other client's list. The workspace toolkit is org-owned, so it | |
| 149 | + // MUST be present. On a multi-tenant target the personal toolkit (a | |
| 150 | + // different member's user-owned data) would be ABSENT; on selfhost idB IS | |
| 151 | + // idA, so it is present — we assert the truth for this target and pin the | |
| 152 | + // workspace-visible invariant unconditionally. | |
| 153 | + const listB = yield* clientB.toolkits.list(); | |
| 154 | + expect( | |
| 155 | + listB.some((t) => t.id === workspaceKit.id && t.slug === workspaceKit.slug), | |
| 156 | + "workspace toolkit is visible to org members", | |
| 157 | + ).toBe(true); | |
| 158 | + expect( | |
| 159 | + listB.some((t) => t.id === personalKit.id), | |
| 160 | + sameSubject | |
| 161 | + ? "selfhost single-subject: idA's personal toolkit is visible because idB IS idA" | |
| 162 | + : "a personal toolkit must not appear in another member's list", | |
| 163 | + ).toBe(sameSubject); | |
| 164 | + | |
| 165 | + // (b) An MCP session for idB scoped to the WORKSPACE slug exposes the org | |
| 166 | + // slice: the execute inventory mentions the org integration, and running | |
| 167 | + // its tool returns the org greeting (a success envelope, not an error). | |
| 168 | + const wsSession = mcp.session(idB, { toolkit: workspaceKit.slug }); | |
| 169 | + const wsDesc = describeExecute(yield* wsSession.describeTools()); | |
| 170 | + expect(wsDesc, "workspace-scoped inventory includes the org integration").toContain(orgSlug); | |
| 171 | + | |
| 172 | + const wsCall = yield* wsSession.call("execute", { | |
| 173 | + code: `return await tools.${orgSlug}.org.${orgConn}.simple_echo({});`, | |
| 174 | + }); | |
| 175 | + expect(wsCall.ok, `org tool executes; text=${wsCall.text}`).toBe(true); | |
| 176 | + expect( | |
| 177 | + wsCall.text, | |
| 178 | + `workspace slice returns the org greeting; text=${wsCall.text}`, | |
| 179 | + ).toContain(orgText); | |
| 180 | + | |
| 181 | + // (c) Fail-closed selector — the mechanism behind cross-user privacy. A | |
| 182 | + // session whose `?toolkit=` slug the caller cannot resolve is narrowed to | |
| 183 | + // an EMPTY slice: the named connection is neither advertised nor runnable. | |
| 184 | + // We exercise it two ways: | |
| 185 | + // 1. the personal slug from another (here: the same) subject's kit, and | |
| 186 | + // 2. a slug that does not exist at all, | |
| 187 | + // and require BOTH to behave identically — proving a personal toolkit | |
| 188 | + // cannot be hijacked by handing its slug to a session that isn't entitled | |
| 189 | + // to it. (Selfhost can't supply a second subject, so the personal slug | |
| 190 | + // DOES resolve here; the bogus slug is the load-bearing fail-closed proof, | |
| 191 | + // and the personal-slug case still confirms the personal connection is not | |
| 192 | + // leaked into an unrelated/empty narrowing.) | |
| 193 | + const hijackSession = mcp.session(idB, { toolkit: ident("ghostkit") }); | |
| 194 | + const hijackDesc = describeExecute(yield* hijackSession.describeTools()); | |
| 195 | + expect( | |
| 196 | + hijackDesc, | |
| 197 | + "an unresolved toolkit slug yields an empty slice — personal connection not exposed", | |
| 198 | + ).not.toContain(personalSlug); | |
| 199 | + expect( | |
| 200 | + hijackDesc, | |
| 201 | + "an unresolved toolkit slug yields an empty slice — org connection not exposed either", | |
| 202 | + ).not.toContain(orgSlug); | |
| 203 | + | |
| 204 | + // Executing the personal connection's tool through the empty/unresolved | |
| 205 | + // slice is blocked: the result is never the personal greeting the tool | |
| 206 | + // would emit if the slice had actually exposed it. | |
| 207 | + const hijackCall = yield* hijackSession.call("execute", { | |
| 208 | + code: `return await tools.${personalSlug}.user.${personalConn}.simple_echo({});`, | |
| 209 | + }); | |
| 210 | + expect( | |
| 211 | + hijackCall.text, | |
| 212 | + `unresolved-slug session must not return the personal greeting; text=${hijackCall.text}`, | |
| 213 | + ).not.toContain(personalText); | |
| 214 | + }), | |
| 215 | + ), | |
| 216 | +); | |
| @@ -0,0 +1,105 @@ | ||
| 1 | +// Selfhost · Slice 1: a toolkit is owner-scoped plugin data with a CRUD API. | |
| 2 | +// Proves the plugin's storage round-trips through real HTTP routes on the | |
| 3 | +// selfhost server, owner/scope maps correctly, and the scope guard rejects | |
| 4 | +// connections a toolkit isn't allowed to reference. No MCP narrowing yet. | |
| 5 | +import { randomBytes } from "node:crypto"; | |
| 6 | + | |
| 7 | +import { expect } from "@effect/vitest"; | |
| 8 | +import { Effect } from "effect"; | |
| 9 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 10 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 11 | +import { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 12 | + | |
| 13 | +import { scenario } from "../src/scenario"; | |
| 14 | +import { Api, Target } from "../src/services"; | |
| 15 | + | |
| 16 | +const api = composePluginApi([toolkitsPlugin()] as const); | |
| 17 | +const fresh = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`; | |
| 18 | + | |
| 19 | +scenario( | |
| 20 | + "Toolkits · CRUD round-trips through the selfhost API", | |
| 21 | + {}, | |
| 22 | + Effect.gen(function* () { | |
| 23 | + const target = yield* Target; | |
| 24 | + const { client: makeApiClient } = yield* Api; | |
| 25 | + const identity = yield* target.newIdentity(); | |
| 26 | + const client = yield* makeApiClient(api, identity); | |
| 27 | + | |
| 28 | + const slug = fresh("work"); | |
| 29 | + const created = yield* client.toolkits.create({ | |
| 30 | + payload: { | |
| 31 | + slug, | |
| 32 | + name: "Work", | |
| 33 | + scope: "personal", | |
| 34 | + briefing: "general assistant", | |
| 35 | + }, | |
| 36 | + }); | |
| 37 | + expect(created.slug).toBe(slug); | |
| 38 | + expect(created.scope).toBe("personal"); | |
| 39 | + expect(created.inheritOrgPolicies, "defaults to inheriting org policies").toBe(true); | |
| 40 | + expect(created.briefing).toBe("general assistant"); | |
| 41 | + expect(created.connections.length).toBe(0); | |
| 42 | + | |
| 43 | + // round-trips through GET | |
| 44 | + const fetched = yield* client.toolkits.get({ params: { id: created.id } }); | |
| 45 | + expect(fetched.id).toBe(created.id); | |
| 46 | + expect(fetched.name).toBe("Work"); | |
| 47 | + | |
| 48 | + // shows up in the owner-scoped list | |
| 49 | + const listed = yield* client.toolkits.list(); | |
| 50 | + expect( | |
| 51 | + listed.some((t) => t.id === created.id), | |
| 52 | + "the created toolkit appears in the list", | |
| 53 | + ).toBe(true); | |
| 54 | + | |
| 55 | + // PATCH mutates name + briefing | |
| 56 | + const patched = yield* client.toolkits.update({ | |
| 57 | + params: { id: created.id }, | |
| 58 | + payload: { name: "Work v2", briefing: null }, | |
| 59 | + }); | |
| 60 | + expect(patched.name).toBe("Work v2"); | |
| 61 | + expect(patched.briefing).toBe(null); | |
| 62 | + | |
| 63 | + // DELETE removes it; subsequent GET is a typed ToolkitNotFound | |
| 64 | + const removed = yield* client.toolkits.remove({ | |
| 65 | + params: { id: created.id }, | |
| 66 | + }); | |
| 67 | + expect(removed.removed).toBe(true); | |
| 68 | + | |
| 69 | + const afterDelete = yield* Effect.flip(client.toolkits.get({ params: { id: created.id } })); | |
| 70 | + expect((afterDelete as { _tag?: string })._tag, "GET after delete is ToolkitNotFound").toBe( | |
| 71 | + "ToolkitNotFound", | |
| 72 | + ); | |
| 73 | + }), | |
| 74 | +); | |
| 75 | + | |
| 76 | +scenario( | |
| 77 | + "Toolkits · a workspace toolkit cannot reference a connection it isn't allowed to use", | |
| 78 | + {}, | |
| 79 | + Effect.gen(function* () { | |
| 80 | + const target = yield* Target; | |
| 81 | + const { client: makeApiClient } = yield* Api; | |
| 82 | + const identity = yield* target.newIdentity(); | |
| 83 | + const client = yield* makeApiClient(api, identity); | |
| 84 | + | |
| 85 | + const result = yield* Effect.flip( | |
| 86 | + client.toolkits.create({ | |
| 87 | + payload: { | |
| 88 | + slug: fresh("support"), | |
| 89 | + name: "Support", | |
| 90 | + scope: "workspace", | |
| 91 | + connections: [ | |
| 92 | + { | |
| 93 | + integration: IntegrationSlug.make("slack"), | |
| 94 | + connection: "nope", | |
| 95 | + access: "full", | |
| 96 | + }, | |
| 97 | + ], | |
| 98 | + }, | |
| 99 | + }), | |
| 100 | + ); | |
| 101 | + expect((result as { _tag?: string })._tag, "rejected with ToolkitForbidden").toBe( | |
| 102 | + "ToolkitForbidden", | |
| 103 | + ); | |
| 104 | + }), | |
| 105 | +); | |
| @@ -0,0 +1,158 @@ | ||
| 1 | +// Selfhost · the execute tool's DESCRIPTION accurately reflects a toolkit's | |
| 2 | +// slice. An MCP client reads this description to learn what it can call; if it | |
| 3 | +// still listed out-of-slice connections, a scoped agent would chase addresses | |
| 4 | +// it cannot run. The execute description's "Available connection prefixes" | |
| 5 | +// inventory (`<integration>.<owner>.<connection>` paths) must therefore name | |
| 6 | +// exactly the toolkit's connections — A and B (in the kit), never C (out) — | |
| 7 | +// while a bare session, with no selector, still advertises C. This is the | |
| 8 | +// "the description on MCP clients is right" coverage for toolkit narrowing. | |
| 9 | +import { randomBytes } from "node:crypto"; | |
| 10 | + | |
| 11 | +import { expect } from "@effect/vitest"; | |
| 12 | +import { Effect } from "effect"; | |
| 13 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 14 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 15 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 16 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 17 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 18 | + | |
| 19 | +import { scenario } from "../src/scenario"; | |
| 20 | +import { Api, Mcp, Target } from "../src/services"; | |
| 21 | + | |
| 22 | +const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const); | |
| 23 | +// Identifier-safe (no hyphens) so the sandbox `tools.<int>.<owner>.<conn>.<tool>` | |
| 24 | +// dotted path — and the description's connection-prefix inventory — stay valid. | |
| 25 | +const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 26 | + | |
| 27 | +const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string => | |
| 28 | + defs.find((d) => d.name === "execute")?.description ?? ""; | |
| 29 | + | |
| 30 | +scenario( | |
| 31 | + "Toolkits · the MCP execute tool's description lists exactly the toolkit's connection slice", | |
| 32 | + { timeout: 180_000 }, | |
| 33 | + Effect.scoped( | |
| 34 | + Effect.gen(function* () { | |
| 35 | + const target = yield* Target; | |
| 36 | + const { client: makeApiClient } = yield* Api; | |
| 37 | + const mcp = yield* Mcp; | |
| 38 | + const identity = yield* target.newIdentity(); | |
| 39 | + const client = yield* makeApiClient(api, identity); | |
| 40 | + | |
| 41 | + // Stand up three real MCP greeting servers -> three distinct integrations | |
| 42 | + // + org connections (each exposes a `simple_echo` tool discovered at | |
| 43 | + // create). Connection names are normalized on create (hyphens removed + | |
| 44 | + // camelCased), so we keep them lowercase-alphanumeric to round-trip | |
| 45 | + // unchanged into the description's prefix. | |
| 46 | + const addConnection = (slug: string, conn: string) => | |
| 47 | + Effect.gen(function* () { | |
| 48 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 49 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 50 | + auth: { | |
| 51 | + validateAuthorization: (authorization) => | |
| 52 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 53 | + }, | |
| 54 | + }); | |
| 55 | + yield* client.mcp.addServer({ | |
| 56 | + payload: { | |
| 57 | + transport: "remote", | |
| 58 | + name: `Greeting ${slug}`, | |
| 59 | + endpoint: server.endpoint, | |
| 60 | + slug, | |
| 61 | + authenticationTemplate: [ | |
| 62 | + { | |
| 63 | + type: "apiKey", | |
| 64 | + headers: { | |
| 65 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 66 | + }, | |
| 67 | + }, | |
| 68 | + ], | |
| 69 | + }, | |
| 70 | + }); | |
| 71 | + yield* client.connections.create({ | |
| 72 | + payload: { | |
| 73 | + owner: "org", | |
| 74 | + name: ConnectionName.make(conn), | |
| 75 | + integration: IntegrationSlug.make(slug), | |
| 76 | + template: AuthTemplateSlug.make("header"), | |
| 77 | + value: token, | |
| 78 | + }, | |
| 79 | + }); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + // A and B go in the toolkit (at "full" and "read" respectively); C does not. | |
| 83 | + const slugA = ident("tka"); | |
| 84 | + const slugB = ident("tkb"); | |
| 85 | + const slugC = ident("tkc"); | |
| 86 | + const connA = ident("conn"); | |
| 87 | + const connB = ident("conn"); | |
| 88 | + const connC = ident("conn"); | |
| 89 | + yield* addConnection(slugA, connA); | |
| 90 | + yield* addConnection(slugB, connB); | |
| 91 | + yield* addConnection(slugC, connC); | |
| 92 | + | |
| 93 | + // A workspace (org-owned) toolkit: A at "full", B at "read", C omitted. | |
| 94 | + const kit = yield* client.toolkits.create({ | |
| 95 | + payload: { | |
| 96 | + slug: ident("kit"), | |
| 97 | + name: "Slice kit", | |
| 98 | + scope: "workspace", | |
| 99 | + connections: [ | |
| 100 | + { | |
| 101 | + integration: IntegrationSlug.make(slugA), | |
| 102 | + connection: connA, | |
| 103 | + access: "full", | |
| 104 | + }, | |
| 105 | + { | |
| 106 | + integration: IntegrationSlug.make(slugB), | |
| 107 | + connection: connB, | |
| 108 | + access: "read", | |
| 109 | + }, | |
| 110 | + ], | |
| 111 | + }, | |
| 112 | + }); | |
| 113 | + | |
| 114 | + // The description lists connection prefixes as `<integration>.<owner>.<connection>` | |
| 115 | + // (the `tools.` root is stripped). Workspace connections are owner "org". | |
| 116 | + const prefixA = `${slugA}.org.${connA}`; | |
| 117 | + const prefixB = `${slugB}.org.${connB}`; | |
| 118 | + const prefixC = `${slugC}.org.${connC}`; | |
| 119 | + | |
| 120 | + // Scoped session: the execute description names A and B, never C. | |
| 121 | + const scoped = mcp.session(identity, { toolkit: kit.slug }); | |
| 122 | + const scopedDefs = yield* scoped.describeTools(); | |
| 123 | + const scopedDesc = describeExecute(scopedDefs); | |
| 124 | + | |
| 125 | + expect( | |
| 126 | + scopedDesc, | |
| 127 | + `scoped execute description names the full-access connection (A)`, | |
| 128 | + ).toContain(prefixA); | |
| 129 | + expect( | |
| 130 | + scopedDesc, | |
| 131 | + `scoped execute description names the read-access connection (B)`, | |
| 132 | + ).toContain(prefixB); | |
| 133 | + expect( | |
| 134 | + scopedDesc, | |
| 135 | + `scoped execute description must NOT name the out-of-slice connection (C)`, | |
| 136 | + ).not.toContain(prefixC); | |
| 137 | + // Defense-in-depth: even the bare integration slug for C must not leak into | |
| 138 | + // the scoped inventory (it never appears in any other prefix). | |
| 139 | + expect(scopedDesc, `scoped inventory omits C's integration entirely`).not.toContain(slugC); | |
| 140 | + | |
| 141 | + // The execute tool itself is still advertised on the scoped session — the | |
| 142 | + // slice narrows its inventory, it does not remove the tool. | |
| 143 | + const scopedToolNames = yield* scoped.listTools(); | |
| 144 | + expect(scopedToolNames, "scoped session still advertises execute").toContain("execute"); | |
| 145 | + | |
| 146 | + // Bare session (no selector): the description DOES name C — proving the | |
| 147 | + // scoped omission above is genuine narrowing, not C being absent globally. | |
| 148 | + const bareDefs = yield* mcp.session(identity).describeTools(); | |
| 149 | + const bareDesc = describeExecute(bareDefs); | |
| 150 | + expect(bareDesc, "bare execute description names A").toContain(prefixA); | |
| 151 | + expect(bareDesc, "bare execute description names B").toContain(prefixB); | |
| 152 | + expect( | |
| 153 | + bareDesc, | |
| 154 | + "bare execute description names the out-of-slice connection C (no narrowing)", | |
| 155 | + ).toContain(prefixC); | |
| 156 | + }), | |
| 157 | + ), | |
| 158 | +); | |
| @@ -0,0 +1,135 @@ | ||
| 1 | +// Selfhost · Toolkits fail-closed. SECURITY-CRITICAL: an unknown/invalid | |
| 2 | +// ?toolkit= selector must FAIL CLOSED — the engine narrows to an EMPTY slice | |
| 3 | +// (EMPTY_TOOLKIT_SCOPE, applied by narrowToToolkit when resolveScope returns | |
| 4 | +// null) where every connection tool is blocked — and must NEVER silently fall | |
| 5 | +// back to full (unscoped) account access. | |
| 6 | +// | |
| 7 | +// The control is a BARE session (no selector): the real org connection's tool | |
| 8 | +// RUNS and its result text is the real greeting. The probe is a session scoped | |
| 9 | +// to a bogus selector ("doesnotexist"+hex): the SAME real tool is blocked at | |
| 10 | +// execute (error envelope, not the real greeting) and the bogus inventory lists | |
| 11 | +// no connections. A regression where a bad selector granted full access would | |
| 12 | +// make the bogus execute return the real greeting (== the bare execute) and the | |
| 13 | +// bogus inventory contain the connection's integration — both asserted against. | |
| 14 | +import { randomBytes } from "node:crypto"; | |
| 15 | + | |
| 16 | +import { expect } from "@effect/vitest"; | |
| 17 | +import { Effect } from "effect"; | |
| 18 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 19 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 20 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 21 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 22 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 23 | + | |
| 24 | +import { scenario } from "../src/scenario"; | |
| 25 | +import { Api, Mcp, Target } from "../src/services"; | |
| 26 | + | |
| 27 | +const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const); | |
| 28 | +// Identifier-safe (no hyphens) so the sandbox `tools.<int>.<owner>.<conn>.<tool>` | |
| 29 | +// dotted path stays valid JS, and names survive create normalization. | |
| 30 | +const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 31 | + | |
| 32 | +const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string => | |
| 33 | + defs.find((d) => d.name === "execute")?.description ?? ""; | |
| 34 | + | |
| 35 | +scenario( | |
| 36 | + "Toolkits · a bogus toolkit selector fails closed — empty slice, never full access", | |
| 37 | + { timeout: 180_000 }, | |
| 38 | + Effect.scoped( | |
| 39 | + Effect.gen(function* () { | |
| 40 | + const target = yield* Target; | |
| 41 | + const { client: makeApiClient } = yield* Api; | |
| 42 | + const mcp = yield* Mcp; | |
| 43 | + const identity = yield* target.newIdentity(); | |
| 44 | + const client = yield* makeApiClient(api, identity); | |
| 45 | + | |
| 46 | + // Seed ONE real org connection (a greeting MCP server -> integration + | |
| 47 | + // org connection exposing a `simple_echo` tool discovered at create). | |
| 48 | + const slug = ident("tk"); | |
| 49 | + const conn = ident("conn"); | |
| 50 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 51 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 52 | + auth: { | |
| 53 | + validateAuthorization: (authorization) => | |
| 54 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 55 | + }, | |
| 56 | + }); | |
| 57 | + yield* client.mcp.addServer({ | |
| 58 | + payload: { | |
| 59 | + transport: "remote", | |
| 60 | + name: `Greeting ${slug}`, | |
| 61 | + endpoint: server.endpoint, | |
| 62 | + slug, | |
| 63 | + authenticationTemplate: [ | |
| 64 | + { | |
| 65 | + type: "apiKey", | |
| 66 | + headers: { | |
| 67 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 68 | + }, | |
| 69 | + }, | |
| 70 | + ], | |
| 71 | + }, | |
| 72 | + }); | |
| 73 | + yield* client.connections.create({ | |
| 74 | + payload: { | |
| 75 | + owner: "org", | |
| 76 | + name: ConnectionName.make(conn), | |
| 77 | + integration: IntegrationSlug.make(slug), | |
| 78 | + template: AuthTemplateSlug.make("header"), | |
| 79 | + value: token, | |
| 80 | + }, | |
| 81 | + }); | |
| 82 | + | |
| 83 | + // A real WORKSPACE toolkit referencing the connection — proves the account | |
| 84 | + // genuinely has both runnable tools and a real toolkit, so the only thing | |
| 85 | + // distinguishing the probe is its bogus selector. | |
| 86 | + yield* client.toolkits.create({ | |
| 87 | + payload: { | |
| 88 | + slug: ident("kit"), | |
| 89 | + name: "Real kit", | |
| 90 | + scope: "workspace", | |
| 91 | + connections: [ | |
| 92 | + { | |
| 93 | + integration: IntegrationSlug.make(slug), | |
| 94 | + connection: conn, | |
| 95 | + access: "full", | |
| 96 | + }, | |
| 97 | + ], | |
| 98 | + }, | |
| 99 | + }); | |
| 100 | + | |
| 101 | + const code = `return await tools.${slug}.org.${conn}.simple_echo({});`; | |
| 102 | + | |
| 103 | + // (a) CONTROL — bare session (no selector): the tool RUNS and the | |
| 104 | + // inventory lists the connection's integration. | |
| 105 | + const bare = mcp.session(identity); | |
| 106 | + const bareDesc = describeExecute(yield* bare.describeTools()); | |
| 107 | + expect(bareDesc, "bare inventory includes the seeded integration").toContain(slug); | |
| 108 | + const bareRun = yield* bare.call("execute", { code }); | |
| 109 | + expect(bareRun.ok, `bare tool executes; text=${bareRun.text}`).toBe(true); | |
| 110 | + | |
| 111 | + // (b) PROBE — session scoped to a selector that resolves to no toolkit. | |
| 112 | + // Inventory must list NO connections (only static/core tools survive an | |
| 113 | + // empty slice), and the SAME tool must be BLOCKED at execute. | |
| 114 | + const bogusSelector = ident("doesnotexist"); | |
| 115 | + const bogus = mcp.session(identity, { toolkit: bogusSelector }); | |
| 116 | + const bogusDesc = describeExecute(yield* bogus.describeTools()); | |
| 117 | + const bogusRun = yield* bogus.call("execute", { code }); | |
| 118 | + | |
| 119 | + // (c) STRENGTHEN — unambiguous fail-closed assertions. A regression where | |
| 120 | + // the bogus selector granted full access would fail every one of these: | |
| 121 | + // - the bogus inventory would contain the integration slug, and | |
| 122 | + // - the bogus execute would return the SAME real greeting as the bare run. | |
| 123 | + expect( | |
| 124 | + bogusDesc, | |
| 125 | + `bogus selector must NOT leak the seeded integration into inventory; bogusDesc=${bogusDesc}`, | |
| 126 | + ).not.toContain(slug); | |
| 127 | + // Blocked execute surfaces as an error envelope (.text differs) — never the | |
| 128 | + // real greeting the bare control returns. We compare .text, not .ok. | |
| 129 | + expect( | |
| 130 | + bogusRun.text, | |
| 131 | + `bogus selector must BLOCK the tool, not return the real result; bare.text=${bareRun.text} bogus.ok=${bogusRun.ok} bogus.text=${bogusRun.text}`, | |
| 132 | + ).not.toBe(bareRun.text); | |
| 133 | + }), | |
| 134 | + ), | |
| 135 | +); | |
| @@ -0,0 +1,188 @@ | ||
| 1 | +// Selfhost · org policies vs a toolkit's inheritOrgPolicies flag. | |
| 2 | +// | |
| 3 | +// Org `block` / `require_approval` guardrails always reach scoped sessions; only | |
| 4 | +// org `approve` rows may be dropped when inheritOrgPolicies=false. | |
| 5 | +import { randomBytes } from "node:crypto"; | |
| 6 | + | |
| 7 | +import { expect } from "@effect/vitest"; | |
| 8 | +import { Effect } from "effect"; | |
| 9 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 10 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 11 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 12 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 13 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 14 | + | |
| 15 | +import { scenario } from "../src/scenario"; | |
| 16 | +import { Api, Mcp, Target } from "../src/services"; | |
| 17 | + | |
| 18 | +// The composed API carries the toolkits group, the MCP-server-management group, | |
| 19 | +// AND the core PoliciesApi (included by composePluginApi), so `client.policies.*` | |
| 20 | +// and `client.toolkits.*` are both typed here. | |
| 21 | +const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const); | |
| 22 | + | |
| 23 | +// Identifier-safe (no hyphens) so the sandbox `tools.<int>.<owner>.<conn>.<tool>` | |
| 24 | +// dotted path stays a valid JS member expression and names normalize cleanly. | |
| 25 | +const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 26 | + | |
| 27 | +const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string => | |
| 28 | + defs.find((d) => d.name === "execute")?.description ?? ""; | |
| 29 | + | |
| 30 | +// A `block` surfaces as an error envelope at execute: `.ok` true, the error | |
| 31 | +// (tool_blocked) inside `.text`. A successful greeting never contains this. | |
| 32 | +const isBlocked = (text: string): boolean => text.includes("tool_blocked"); | |
| 33 | + | |
| 34 | +scenario( | |
| 35 | + "Toolkits · an org block policy is enforced via the base executor and reaches a toolkit slice", | |
| 36 | + { timeout: 180_000 }, | |
| 37 | + Effect.scoped( | |
| 38 | + Effect.gen(function* () { | |
| 39 | + const target = yield* Target; | |
| 40 | + const { client: makeApiClient } = yield* Api; | |
| 41 | + const mcp = yield* Mcp; | |
| 42 | + const identity = yield* target.newIdentity(); | |
| 43 | + const client = yield* makeApiClient(api, identity); | |
| 44 | + | |
| 45 | + // One real greeting MCP server -> one integration + an org connection that | |
| 46 | + // exposes a `simple_echo` tool (discovered at connection create). | |
| 47 | + const slug = ident("inh"); | |
| 48 | + const conn = ident("conn"); | |
| 49 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 50 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 51 | + auth: { | |
| 52 | + validateAuthorization: (authorization) => | |
| 53 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 54 | + }, | |
| 55 | + }); | |
| 56 | + yield* client.mcp.addServer({ | |
| 57 | + payload: { | |
| 58 | + transport: "remote", | |
| 59 | + name: `Greeting ${slug}`, | |
| 60 | + endpoint: server.endpoint, | |
| 61 | + slug, | |
| 62 | + authenticationTemplate: [ | |
| 63 | + { | |
| 64 | + type: "apiKey", | |
| 65 | + headers: { | |
| 66 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 67 | + }, | |
| 68 | + }, | |
| 69 | + ], | |
| 70 | + }, | |
| 71 | + }); | |
| 72 | + yield* client.connections.create({ | |
| 73 | + payload: { | |
| 74 | + owner: "org", | |
| 75 | + name: ConnectionName.make(conn), | |
| 76 | + integration: IntegrationSlug.make(slug), | |
| 77 | + template: AuthTemplateSlug.make("header"), | |
| 78 | + value: token, | |
| 79 | + }, | |
| 80 | + }); | |
| 81 | + | |
| 82 | + const code = `return await tools.${slug}.org.${conn}.simple_echo({});`; | |
| 83 | + | |
| 84 | + // Without any org policy, a bare /mcp session runs the tool — the baseline | |
| 85 | + // "this connection works" before the org guardrail is in place. | |
| 86 | + const beforePolicy = yield* mcp.session(identity).call("execute", { code }); | |
| 87 | + expect( | |
| 88 | + beforePolicy.ok && !isBlocked(beforePolicy.text), | |
| 89 | + `connection works before the org policy; text=${beforePolicy.text}`, | |
| 90 | + ).toBe(true); | |
| 91 | + | |
| 92 | + // ORG-LEVEL block policy over the connection's tool. The org `tool_policy` | |
| 93 | + // matcher tests the 4-segment `<integration>.<owner>.<connection>.<tool>` | |
| 94 | + // address, so `<slug>.*` (a trailing-`*` subtree) blocks | |
| 95 | + // `<slug>.org.<conn>.simple_echo` for this org outside any toolkit. | |
| 96 | + const orgPattern = `${slug}.*`; | |
| 97 | + const orgPolicy = yield* client.policies.create({ | |
| 98 | + payload: { owner: "org", pattern: orgPattern, action: "block" }, | |
| 99 | + }); | |
| 100 | + expect(orgPolicy.owner, "the org policy is owned by the org").toBe("org"); | |
| 101 | + expect(orgPolicy.action, "the org policy blocks").toBe("block"); | |
| 102 | + expect(orgPolicy.pattern, "the org policy targets the connection's tool").toBe(orgPattern); | |
| 103 | + | |
| 104 | + // The org block is real: the bare session (no toolkit) is now blocked. | |
| 105 | + const bareCall = yield* mcp.session(identity).call("execute", { code }); | |
| 106 | + expect( | |
| 107 | + isBlocked(bareCall.text), | |
| 108 | + `org block applies to the bare session; before=${beforePolicy.text} after=${bareCall.text}`, | |
| 109 | + ).toBe(true); | |
| 110 | + | |
| 111 | + // Two workspace toolkits over the SAME connection, both at full access, | |
| 112 | + // differing ONLY in inheritOrgPolicies. | |
| 113 | + const inherit = yield* client.toolkits.create({ | |
| 114 | + payload: { | |
| 115 | + slug: ident("kitinh"), | |
| 116 | + name: "Inherits org", | |
| 117 | + scope: "workspace", | |
| 118 | + inheritOrgPolicies: true, | |
| 119 | + connections: [ | |
| 120 | + { | |
| 121 | + integration: IntegrationSlug.make(slug), | |
| 122 | + connection: conn, | |
| 123 | + access: "full", | |
| 124 | + }, | |
| 125 | + ], | |
| 126 | + }, | |
| 127 | + }); | |
| 128 | + expect(inherit.inheritOrgPolicies, "T_inherit flag round-trips as true").toBe(true); | |
| 129 | + | |
| 130 | + const isolated = yield* client.toolkits.create({ | |
| 131 | + payload: { | |
| 132 | + slug: ident("kitiso"), | |
| 133 | + name: "Isolated", | |
| 134 | + scope: "workspace", | |
| 135 | + inheritOrgPolicies: false, | |
| 136 | + connections: [ | |
| 137 | + { | |
| 138 | + integration: IntegrationSlug.make(slug), | |
| 139 | + connection: conn, | |
| 140 | + access: "full", | |
| 141 | + }, | |
| 142 | + ], | |
| 143 | + }, | |
| 144 | + }); | |
| 145 | + expect(isolated.inheritOrgPolicies, "T_isolated flag round-trips as false").toBe(false); | |
| 146 | + | |
| 147 | + // Both slices grant the connection at full, so neither hides it for lack of | |
| 148 | + // access — any block is the org policy reaching the slice, not the grant. | |
| 149 | + const inheritDesc = describeExecute( | |
| 150 | + yield* mcp.session(identity, { toolkit: inherit.slug }).describeTools(), | |
| 151 | + ); | |
| 152 | + const isolatedDesc = describeExecute( | |
| 153 | + yield* mcp.session(identity, { toolkit: isolated.slug }).describeTools(), | |
| 154 | + ); | |
| 155 | + expect(inheritDesc, "T_inherit slice scopes to the integration").toContain(slug); | |
| 156 | + expect(isolatedDesc, "T_isolated slice scopes to the integration").toContain(slug); | |
| 157 | + | |
| 158 | + // Execute the SAME code in each scoped session. | |
| 159 | + const inheritCall = yield* mcp | |
| 160 | + .session(identity, { toolkit: inherit.slug }) | |
| 161 | + .call("execute", { code }); | |
| 162 | + const isolatedCall = yield* mcp | |
| 163 | + .session(identity, { toolkit: isolated.slug }) | |
| 164 | + .call("execute", { code }); | |
| 165 | + | |
| 166 | + // T_inherit inherits the org block (the intended contract for true): blocked. | |
| 167 | + expect( | |
| 168 | + isBlocked(inheritCall.text), | |
| 169 | + `T_inherit (inheritOrgPolicies:true) inherits the org block; text=${inheritCall.text}`, | |
| 170 | + ).toBe(true); | |
| 171 | + | |
| 172 | + // T_isolated still inherits org block/require_approval guardrails even when | |
| 173 | + // inheritOrgPolicies:false — a toolkit may only tighten, never loosen. | |
| 174 | + expect( | |
| 175 | + isBlocked(isolatedCall.text), | |
| 176 | + `T_isolated (inheritOrgPolicies:false) still blocked by org guardrail; text=${isolatedCall.text}`, | |
| 177 | + ).toBe(true); | |
| 178 | + const inheritView = yield* client.toolkits.get({ | |
| 179 | + params: { id: inherit.id }, | |
| 180 | + }); | |
| 181 | + const isolatedView = yield* client.toolkits.get({ | |
| 182 | + params: { id: isolated.id }, | |
| 183 | + }); | |
| 184 | + expect(inheritView.inheritOrgPolicies, "view preserves true").toBe(true); | |
| 185 | + expect(isolatedView.inheritOrgPolicies, "view preserves false").toBe(false); | |
| 186 | + }), | |
| 187 | + ), | |
| 188 | +); | |
| @@ -0,0 +1,184 @@ | ||
| 1 | +// Selfhost · Toolkit slice isolation: two toolkits over OVERLAPPING connections | |
| 2 | +// each expose only their own slice. Seed three integrations A/B/C with one org | |
| 3 | +// connection apiece; T1 = {A, B}, T2 = {B, C} (B is shared). A session scoped to | |
| 4 | +// T1 can run A and B but is BLOCKED on C; a session scoped to T2 can run B and C | |
| 5 | +// but is BLOCKED on A. The shared B never carries the non-shared members across: | |
| 6 | +// C is reachable under T2 yet blocked under T1, and A is reachable under T1 yet | |
| 7 | +// blocked under T2 — proving the slices don't leak into one another. | |
| 8 | +import { randomBytes } from "node:crypto"; | |
| 9 | + | |
| 10 | +import { expect } from "@effect/vitest"; | |
| 11 | +import { Effect } from "effect"; | |
| 12 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 13 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 14 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 15 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 16 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 17 | + | |
| 18 | +import { scenario } from "../src/scenario"; | |
| 19 | +import { Api, Mcp, Target } from "../src/services"; | |
| 20 | + | |
| 21 | +const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const); | |
| 22 | +// Identifier-safe (no hyphens, lowercase-alphanumeric) so the sandbox | |
| 23 | +// `tools.<int>.<owner>.<conn>.<tool>` dotted path stays valid JS and the | |
| 24 | +// connection name survives create-time normalization unchanged. | |
| 25 | +const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 26 | + | |
| 27 | +const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string => | |
| 28 | + defs.find((d) => d.name === "execute")?.description ?? ""; | |
| 29 | + | |
| 30 | +scenario( | |
| 31 | + "Toolkits · two toolkits over overlapping connections each see only their own slice; no cross-leak", | |
| 32 | + { timeout: 180_000 }, | |
| 33 | + Effect.scoped( | |
| 34 | + Effect.gen(function* () { | |
| 35 | + const target = yield* Target; | |
| 36 | + const { client: makeApiClient } = yield* Api; | |
| 37 | + const mcp = yield* Mcp; | |
| 38 | + const identity = yield* target.newIdentity(); | |
| 39 | + const client = yield* makeApiClient(api, identity); | |
| 40 | + | |
| 41 | + // Stand up a real MCP greeting server per integration -> one org | |
| 42 | + // connection each (exposes a `simple_echo` tool discovered at create). | |
| 43 | + const addConnection = (slug: string, conn: string) => | |
| 44 | + Effect.gen(function* () { | |
| 45 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 46 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 47 | + auth: { | |
| 48 | + validateAuthorization: (authorization) => | |
| 49 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 50 | + }, | |
| 51 | + }); | |
| 52 | + yield* client.mcp.addServer({ | |
| 53 | + payload: { | |
| 54 | + transport: "remote", | |
| 55 | + name: `Greeting ${slug}`, | |
| 56 | + endpoint: server.endpoint, | |
| 57 | + slug, | |
| 58 | + authenticationTemplate: [ | |
| 59 | + { | |
| 60 | + type: "apiKey", | |
| 61 | + headers: { | |
| 62 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 63 | + }, | |
| 64 | + }, | |
| 65 | + ], | |
| 66 | + }, | |
| 67 | + }); | |
| 68 | + yield* client.connections.create({ | |
| 69 | + payload: { | |
| 70 | + owner: "org", | |
| 71 | + name: ConnectionName.make(conn), | |
| 72 | + integration: IntegrationSlug.make(slug), | |
| 73 | + template: AuthTemplateSlug.make("header"), | |
| 74 | + value: token, | |
| 75 | + }, | |
| 76 | + }); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + // Three distinct integrations, one org connection each. | |
| 80 | + const slugA = ident("tka"); | |
| 81 | + const slugB = ident("tkb"); | |
| 82 | + const slugC = ident("tkc"); | |
| 83 | + const connA = ident("conn"); | |
| 84 | + const connB = ident("conn"); | |
| 85 | + const connC = ident("conn"); | |
| 86 | + yield* addConnection(slugA, connA); | |
| 87 | + yield* addConnection(slugB, connB); | |
| 88 | + yield* addConnection(slugC, connC); | |
| 89 | + | |
| 90 | + // T1 = { A full, B full }; T2 = { B full, C full }. B is shared. | |
| 91 | + const t1 = yield* client.toolkits.create({ | |
| 92 | + payload: { | |
| 93 | + slug: ident("kit"), | |
| 94 | + name: "Toolkit AB", | |
| 95 | + scope: "workspace", | |
| 96 | + connections: [ | |
| 97 | + { | |
| 98 | + integration: IntegrationSlug.make(slugA), | |
| 99 | + connection: connA, | |
| 100 | + access: "full", | |
| 101 | + }, | |
| 102 | + { | |
| 103 | + integration: IntegrationSlug.make(slugB), | |
| 104 | + connection: connB, | |
| 105 | + access: "full", | |
| 106 | + }, | |
| 107 | + ], | |
| 108 | + }, | |
| 109 | + }); | |
| 110 | + const t2 = yield* client.toolkits.create({ | |
| 111 | + payload: { | |
| 112 | + slug: ident("kit"), | |
| 113 | + name: "Toolkit BC", | |
| 114 | + scope: "workspace", | |
| 115 | + connections: [ | |
| 116 | + { | |
| 117 | + integration: IntegrationSlug.make(slugB), | |
| 118 | + connection: connB, | |
| 119 | + access: "full", | |
| 120 | + }, | |
| 121 | + { | |
| 122 | + integration: IntegrationSlug.make(slugC), | |
| 123 | + connection: connC, | |
| 124 | + access: "full", | |
| 125 | + }, | |
| 126 | + ], | |
| 127 | + }, | |
| 128 | + }); | |
| 129 | + | |
| 130 | + // Address every connection's tool by its dotted sandbox path. | |
| 131 | + const callA = `return await tools.${slugA}.org.${connA}.simple_echo({});`; | |
| 132 | + const callB = `return await tools.${slugB}.org.${connB}.simple_echo({});`; | |
| 133 | + const callC = `return await tools.${slugC}.org.${connC}.simple_echo({});`; | |
| 134 | + | |
| 135 | + // ---- Session scoped to T1 (sees A + B, never C) ---- | |
| 136 | + const s1 = mcp.session(identity, { toolkit: t1.slug }); | |
| 137 | + const d1 = describeExecute(yield* s1.describeTools()); | |
| 138 | + expect(d1, "T1 inventory includes A").toContain(slugA); | |
| 139 | + expect(d1, "T1 inventory includes B").toContain(slugB); | |
| 140 | + expect(d1, "T1 inventory omits C").not.toContain(slugC); | |
| 141 | + | |
| 142 | + const s1a = yield* s1.call("execute", { code: callA }); | |
| 143 | + const s1b = yield* s1.call("execute", { code: callB }); | |
| 144 | + const s1c = yield* s1.call("execute", { code: callC }); | |
| 145 | + // In-slice A and B run to the real greeting; out-of-slice C is blocked at | |
| 146 | + // execute and surfaces as an error envelope (never the in-slice text). | |
| 147 | + expect(s1a.text, `T1·A should run; text=${s1a.text}`).not.toBe(""); | |
| 148 | + expect(s1b.text, `T1·B should run; text=${s1b.text}`).not.toBe(""); | |
| 149 | + expect(s1c.text, `T1·C must be blocked; a.text=${s1a.text} c.text=${s1c.text}`).not.toBe( | |
| 150 | + s1a.text, | |
| 151 | + ); | |
| 152 | + expect(s1c.text, `T1·C blocked text differs from B; b.text=${s1b.text}`).not.toBe(s1b.text); | |
| 153 | + | |
| 154 | + // ---- Session scoped to T2 (sees B + C, never A) ---- | |
| 155 | + const s2 = mcp.session(identity, { toolkit: t2.slug }); | |
| 156 | + const d2 = describeExecute(yield* s2.describeTools()); | |
| 157 | + expect(d2, "T2 inventory includes B").toContain(slugB); | |
| 158 | + expect(d2, "T2 inventory includes C").toContain(slugC); | |
| 159 | + expect(d2, "T2 inventory omits A").not.toContain(slugA); | |
| 160 | + | |
| 161 | + const s2b = yield* s2.call("execute", { code: callB }); | |
| 162 | + const s2c = yield* s2.call("execute", { code: callC }); | |
| 163 | + const s2a = yield* s2.call("execute", { code: callA }); | |
| 164 | + // In-slice B and C run; out-of-slice A is blocked. | |
| 165 | + expect(s2b.text, `T2·B should run; text=${s2b.text}`).not.toBe(""); | |
| 166 | + expect(s2c.text, `T2·C should run; text=${s2c.text}`).not.toBe(""); | |
| 167 | + expect(s2a.text, `T2·A must be blocked; c.text=${s2c.text} a.text=${s2a.text}`).not.toBe( | |
| 168 | + s2c.text, | |
| 169 | + ); | |
| 170 | + expect(s2a.text, `T2·A blocked text differs from B; b.text=${s2b.text}`).not.toBe(s2b.text); | |
| 171 | + | |
| 172 | + // ---- The cross term: shared B does not drag its other toolkit's members | |
| 173 | + // across. C is reachable under T2 but blocked under T1; A is reachable | |
| 174 | + // under T1 but blocked under T2. The two slices stay disjoint on their | |
| 175 | + // non-shared members even though both contain B. ---- | |
| 176 | + expect(d1, "C never appears in T1's inventory (only in T2's)").not.toContain(slugC); | |
| 177 | + expect(d2, "A never appears in T2's inventory (only in T1's)").not.toContain(slugA); | |
| 178 | + // C's reachable text under T2 is exactly what's denied under T1. | |
| 179 | + expect(s2c.text, "C runs under T2 but its T1 attempt was blocked").not.toBe(s1c.text); | |
| 180 | + // A's reachable text under T1 is exactly what's denied under T2. | |
| 181 | + expect(s1a.text, "A runs under T1 but its T2 attempt was blocked").not.toBe(s2a.text); | |
| 182 | + }), | |
| 183 | + ), | |
| 184 | +); | |
| @@ -0,0 +1,130 @@ | ||
| 1 | +// Selfhost · Slice 3 (keystone): a toolkit narrows the MCP engine. Connecting | |
| 2 | +// to /mcp?toolkit=<slug> exposes ONLY the toolkit's slice — the execute tool's | |
| 3 | +// inventory omits out-of-slice connections, and an out-of-slice tool is blocked | |
| 4 | +// at execute (not merely hidden), proving enforcement is at the executor, not | |
| 5 | +// just listing. A bare session (no selector) is unchanged. | |
| 6 | +import { randomBytes } from "node:crypto"; | |
| 7 | + | |
| 8 | +import { expect } from "@effect/vitest"; | |
| 9 | +import { Effect } from "effect"; | |
| 10 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 11 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 12 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 13 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 14 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 15 | + | |
| 16 | +import { scenario } from "../src/scenario"; | |
| 17 | +import { Api, Mcp, Target } from "../src/services"; | |
| 18 | + | |
| 19 | +const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const); | |
| 20 | +// Identifier-safe (no hyphens) so the sandbox `tools.<int>.<owner>.<conn>.<tool>` | |
| 21 | +// dotted path stays valid JS. | |
| 22 | +const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 23 | + | |
| 24 | +const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string => | |
| 25 | + defs.find((d) => d.name === "execute")?.description ?? ""; | |
| 26 | + | |
| 27 | +scenario( | |
| 28 | + "Toolkits · an MCP session scoped to a toolkit sees only its slice; out-of-slice is blocked", | |
| 29 | + { timeout: 180_000 }, | |
| 30 | + Effect.scoped( | |
| 31 | + Effect.gen(function* () { | |
| 32 | + const target = yield* Target; | |
| 33 | + const { client: makeApiClient } = yield* Api; | |
| 34 | + const mcp = yield* Mcp; | |
| 35 | + const identity = yield* target.newIdentity(); | |
| 36 | + const client = yield* makeApiClient(api, identity); | |
| 37 | + | |
| 38 | + // Stand up two real MCP greeting servers -> two integrations + org | |
| 39 | + // connections (each exposes a `simple_echo` tool discovered at create). | |
| 40 | + const addConnection = (slug: string, conn: string) => | |
| 41 | + Effect.gen(function* () { | |
| 42 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 43 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 44 | + auth: { | |
| 45 | + validateAuthorization: (authorization) => | |
| 46 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 47 | + }, | |
| 48 | + }); | |
| 49 | + yield* client.mcp.addServer({ | |
| 50 | + payload: { | |
| 51 | + transport: "remote", | |
| 52 | + name: `Greeting ${slug}`, | |
| 53 | + endpoint: server.endpoint, | |
| 54 | + slug, | |
| 55 | + authenticationTemplate: [ | |
| 56 | + { | |
| 57 | + type: "apiKey", | |
| 58 | + headers: { | |
| 59 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 60 | + }, | |
| 61 | + }, | |
| 62 | + ], | |
| 63 | + }, | |
| 64 | + }); | |
| 65 | + yield* client.connections.create({ | |
| 66 | + payload: { | |
| 67 | + owner: "org", | |
| 68 | + name: ConnectionName.make(conn), | |
| 69 | + integration: IntegrationSlug.make(slug), | |
| 70 | + template: AuthTemplateSlug.make("header"), | |
| 71 | + value: token, | |
| 72 | + }, | |
| 73 | + }); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + const slugIn = ident("tkin"); | |
| 77 | + const slugOut = ident("tkout"); | |
| 78 | + const connIn = ident("conn"); | |
| 79 | + const connOut = ident("conn"); | |
| 80 | + yield* addConnection(slugIn, connIn); | |
| 81 | + yield* addConnection(slugOut, connOut); | |
| 82 | + | |
| 83 | + // A workspace toolkit that includes ONLY the first connection. | |
| 84 | + const kit = yield* client.toolkits.create({ | |
| 85 | + payload: { | |
| 86 | + slug: ident("kit"), | |
| 87 | + name: "Scoped kit", | |
| 88 | + scope: "workspace", | |
| 89 | + connections: [ | |
| 90 | + { | |
| 91 | + integration: IntegrationSlug.make(slugIn), | |
| 92 | + connection: connIn, | |
| 93 | + access: "full", | |
| 94 | + }, | |
| 95 | + ], | |
| 96 | + }, | |
| 97 | + }); | |
| 98 | + | |
| 99 | + // Scoped session: inventory shows the in-slice integration, not the other. | |
| 100 | + const scoped = mcp.session(identity, { toolkit: kit.slug }); | |
| 101 | + const scopedDesc = describeExecute(yield* scoped.describeTools()); | |
| 102 | + expect(scopedDesc, "scoped inventory includes the in-slice integration").toContain(slugIn); | |
| 103 | + expect(scopedDesc, "scoped inventory omits the out-of-slice integration").not.toContain( | |
| 104 | + slugOut, | |
| 105 | + ); | |
| 106 | + | |
| 107 | + // In-slice tool runs; out-of-slice tool is blocked at execute (even though | |
| 108 | + // the agent guessed its address). | |
| 109 | + const inSlice = yield* scoped.call("execute", { | |
| 110 | + code: `return await tools.${slugIn}.org.${connIn}.simple_echo({});`, | |
| 111 | + }); | |
| 112 | + expect(inSlice.ok, `in-slice tool executes; text=${inSlice.text}`).toBe(true); | |
| 113 | + | |
| 114 | + const outSlice = yield* scoped.call("execute", { | |
| 115 | + code: `return await tools.${slugOut}.org.${connOut}.simple_echo({});`, | |
| 116 | + }); | |
| 117 | + // Blocked tools surface as an error (thrown or an error envelope) — never | |
| 118 | + // the successful greeting the in-slice call returns. | |
| 119 | + expect( | |
| 120 | + outSlice.text, | |
| 121 | + `out-of-slice must be blocked; in.text=${inSlice.text} out.ok=${outSlice.ok} out.text=${outSlice.text}`, | |
| 122 | + ).not.toBe(inSlice.text); | |
| 123 | + | |
| 124 | + // Bare session (no selector) is unchanged — both integrations visible. | |
| 125 | + const bareDesc = describeExecute(yield* mcp.session(identity).describeTools()); | |
| 126 | + expect(bareDesc, "bare /mcp still sees the first integration").toContain(slugIn); | |
| 127 | + expect(bareDesc, "bare /mcp still sees the second integration").toContain(slugOut); | |
| 128 | + }), | |
| 129 | + ), | |
| 130 | +); | |
| @@ -0,0 +1,135 @@ | ||
| 1 | +// Selfhost · Slice 4: per-toolkit policies + access modes at execute time. | |
| 2 | +// - full access -> the connection's tool runs; | |
| 3 | +// - a `block` policy on that tool -> blocked at execute (exclusion); | |
| 4 | +// - read-only access -> an unclassified (non-read-only) tool is blocked | |
| 5 | +// (fail-closed: unclassified counts as write). | |
| 6 | +// One greeting connection, three toolkits over it, compared via MCP execute. | |
| 7 | +import { randomBytes } from "node:crypto"; | |
| 8 | + | |
| 9 | +import { expect } from "@effect/vitest"; | |
| 10 | +import { Effect } from "effect"; | |
| 11 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 12 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 13 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 14 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 15 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 16 | + | |
| 17 | +import { scenario } from "../src/scenario"; | |
| 18 | +import { Api, Mcp, Target } from "../src/services"; | |
| 19 | + | |
| 20 | +const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const); | |
| 21 | +const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 22 | + | |
| 23 | +scenario( | |
| 24 | + "Toolkits · access modes + block policies are enforced at execute", | |
| 25 | + { timeout: 180_000 }, | |
| 26 | + Effect.scoped( | |
| 27 | + Effect.gen(function* () { | |
| 28 | + const target = yield* Target; | |
| 29 | + const { client: makeApiClient } = yield* Api; | |
| 30 | + const mcp = yield* Mcp; | |
| 31 | + const identity = yield* target.newIdentity(); | |
| 32 | + const client = yield* makeApiClient(api, identity); | |
| 33 | + | |
| 34 | + const slug = ident("pol"); | |
| 35 | + const conn = ident("conn"); | |
| 36 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 37 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 38 | + auth: { | |
| 39 | + validateAuthorization: (authorization) => | |
| 40 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 41 | + }, | |
| 42 | + }); | |
| 43 | + yield* client.mcp.addServer({ | |
| 44 | + payload: { | |
| 45 | + transport: "remote", | |
| 46 | + name: `Greeting ${slug}`, | |
| 47 | + endpoint: server.endpoint, | |
| 48 | + slug, | |
| 49 | + authenticationTemplate: [ | |
| 50 | + { | |
| 51 | + type: "apiKey", | |
| 52 | + headers: { | |
| 53 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 54 | + }, | |
| 55 | + }, | |
| 56 | + ], | |
| 57 | + }, | |
| 58 | + }); | |
| 59 | + yield* client.connections.create({ | |
| 60 | + payload: { | |
| 61 | + owner: "org", | |
| 62 | + name: ConnectionName.make(conn), | |
| 63 | + integration: IntegrationSlug.make(slug), | |
| 64 | + template: AuthTemplateSlug.make("header"), | |
| 65 | + value: token, | |
| 66 | + }, | |
| 67 | + }); | |
| 68 | + | |
| 69 | + const full = yield* client.toolkits.create({ | |
| 70 | + payload: { | |
| 71 | + slug: ident("kitfull"), | |
| 72 | + name: "Full", | |
| 73 | + scope: "workspace", | |
| 74 | + connections: [ | |
| 75 | + { | |
| 76 | + integration: IntegrationSlug.make(slug), | |
| 77 | + connection: conn, | |
| 78 | + access: "full", | |
| 79 | + }, | |
| 80 | + ], | |
| 81 | + }, | |
| 82 | + }); | |
| 83 | + const blocked = yield* client.toolkits.create({ | |
| 84 | + payload: { | |
| 85 | + slug: ident("kitblock"), | |
| 86 | + name: "Blocked", | |
| 87 | + scope: "workspace", | |
| 88 | + connections: [ | |
| 89 | + { | |
| 90 | + integration: IntegrationSlug.make(slug), | |
| 91 | + connection: conn, | |
| 92 | + access: "full", | |
| 93 | + }, | |
| 94 | + ], | |
| 95 | + policies: [{ pattern: `${slug}.${conn}.simple_echo`, action: "block" }], | |
| 96 | + }, | |
| 97 | + }); | |
| 98 | + const readonly = yield* client.toolkits.create({ | |
| 99 | + payload: { | |
| 100 | + slug: ident("kitread"), | |
| 101 | + name: "Read", | |
| 102 | + scope: "workspace", | |
| 103 | + connections: [ | |
| 104 | + { | |
| 105 | + integration: IntegrationSlug.make(slug), | |
| 106 | + connection: conn, | |
| 107 | + access: "read", | |
| 108 | + }, | |
| 109 | + ], | |
| 110 | + }, | |
| 111 | + }); | |
| 112 | + | |
| 113 | + const code = `return await tools.${slug}.org.${conn}.simple_echo({});`; | |
| 114 | + const fullCall = yield* mcp | |
| 115 | + .session(identity, { toolkit: full.slug }) | |
| 116 | + .call("execute", { code }); | |
| 117 | + const blockCall = yield* mcp | |
| 118 | + .session(identity, { toolkit: blocked.slug }) | |
| 119 | + .call("execute", { code }); | |
| 120 | + const readCall = yield* mcp | |
| 121 | + .session(identity, { toolkit: readonly.slug }) | |
| 122 | + .call("execute", { code }); | |
| 123 | + | |
| 124 | + expect(fullCall.ok, `full access runs the tool; text=${fullCall.text}`).toBe(true); | |
| 125 | + expect( | |
| 126 | + blockCall.text, | |
| 127 | + `block policy must stop the tool; full=${fullCall.text} block=${blockCall.text}`, | |
| 128 | + ).not.toBe(fullCall.text); | |
| 129 | + expect( | |
| 130 | + readCall.text, | |
| 131 | + `read-only must hide an unclassified (write) tool; full=${fullCall.text} read=${readCall.text}`, | |
| 132 | + ).not.toBe(fullCall.text); | |
| 133 | + }), | |
| 134 | + ), | |
| 135 | +); | |
| @@ -0,0 +1,285 @@ | ||
| 1 | +// Selfhost · Toolkit access "read" exposes only read-only tools and blocks | |
| 2 | +// writes. `readOnly` is derived from HTTP method on OpenAPI integrations | |
| 3 | +// (GET/HEAD/OPTIONS => readOnly:true; POST/PUT/PATCH/DELETE are writes), and | |
| 4 | +// Read-only toolkit access keeps a connection's tool under "read" ONLY when | |
| 5 | +// `annotations.readOnly === true`. So we stand up a real OpenAPI upstream with | |
| 6 | +// BOTH a GET (read) and a POST (write) operation, grant the same connection at | |
| 7 | +// "read" in one workspace toolkit and "full" in another, and prove: | |
| 8 | +// - the read toolkit's scoped MCP session runs the GET-derived tool but | |
| 9 | +// BLOCKS the POST-derived tool at execute (error envelope, not the success | |
| 10 | +// payload) and omits it from the scoped inventory; | |
| 11 | +// - the full toolkit's scoped session runs BOTH. | |
| 12 | +// | |
| 13 | +// The upstream is a real `node:http` listening socket in THIS test process — | |
| 14 | +// the same cross-process pattern `serveMcpServer` uses (the OpenAPI echo helper | |
| 15 | +// is in-memory via NodeHttpServer.layerTest and is NOT reachable from the | |
| 16 | +// separately-booted selfhost server). The selfhost boot sets | |
| 17 | +// EXECUTOR_ALLOW_LOCAL_NETWORK=true, so the instance is allowed to dial the | |
| 18 | +// loopback upstream. The spec itself is passed inline (blob) so the spec fetch | |
| 19 | +// never hits the network — only the GET/POST invocations dial the socket. | |
| 20 | +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | |
| 21 | +import { randomBytes } from "node:crypto"; | |
| 22 | + | |
| 23 | +import { expect } from "@effect/vitest"; | |
| 24 | +import { Effect, Scope } from "effect"; | |
| 25 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 26 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 27 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 28 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 29 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 30 | + | |
| 31 | +import { scenario } from "../src/scenario"; | |
| 32 | +import { Api, Mcp, Target } from "../src/services"; | |
| 33 | + | |
| 34 | +const api = composePluginApi([mcpHttpPlugin(), openApiHttpPlugin(), toolkitsPlugin()] as const); | |
| 35 | + | |
| 36 | +// Identifier-safe (no hyphens) so the sandbox `tools.<int>.<owner>.<conn>.<tool>` | |
| 37 | +// dotted path stays valid JS, and so the create-time name normalization (which | |
| 38 | +// strips hyphens) round-trips unchanged. | |
| 39 | +const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 40 | + | |
| 41 | +const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string => | |
| 42 | + defs.find((d) => d.name === "execute")?.description ?? ""; | |
| 43 | + | |
| 44 | +// A real OpenAPI upstream on a loopback socket. `GET /thing` is a read; `POST | |
| 45 | +// /thing` is a write. Both return a recognizable JSON marker so a successful | |
| 46 | +// invocation is distinguishable from a blocked one. The bearer token is | |
| 47 | +// required so the call genuinely goes through the connection's credential. | |
| 48 | +interface Upstream { | |
| 49 | + readonly baseUrl: string; | |
| 50 | +} | |
| 51 | + | |
| 52 | +// Mirrors serveMcpServer: acquire a real listening socket carrying its own | |
| 53 | +// `close`, release closes it, and we strip `close` from the value handed back. | |
| 54 | +const serveUpstream = (token: string): Effect.Effect<Upstream, never, Scope.Scope> => | |
| 55 | + Effect.acquireRelease( | |
| 56 | + Effect.callback<Upstream & { readonly close: Effect.Effect<void> }, never>((resume) => { | |
| 57 | + const server = createServer((request: IncomingMessage, response: ServerResponse) => { | |
| 58 | + const url = new URL(request.url ?? "/", "http://127.0.0.1"); | |
| 59 | + const auth = request.headers.authorization; | |
| 60 | + const send = (status: number, body: Record<string, unknown>): void => { | |
| 61 | + response.writeHead(status, { | |
| 62 | + "content-type": "application/json", | |
| 63 | + }); | |
| 64 | + response.end(JSON.stringify(body)); | |
| 65 | + }; | |
| 66 | + if (auth !== `Bearer ${token}`) { | |
| 67 | + send(401, { error: "unauthorized" }); | |
| 68 | + return; | |
| 69 | + } | |
| 70 | + if (url.pathname === "/thing" && request.method === "GET") { | |
| 71 | + send(200, { marker: "read-ok", kind: "get" }); | |
| 72 | + return; | |
| 73 | + } | |
| 74 | + if (url.pathname === "/thing" && request.method === "POST") { | |
| 75 | + // Drain the body so the socket closes cleanly, then ack. | |
| 76 | + request.resume(); | |
| 77 | + request.on("end", () => send(201, { marker: "write-ok", kind: "post" })); | |
| 78 | + return; | |
| 79 | + } | |
| 80 | + send(404, { error: "not_found" }); | |
| 81 | + }); | |
| 82 | + const close = Effect.sync(() => { | |
| 83 | + server.close(); | |
| 84 | + server.closeAllConnections?.(); | |
| 85 | + }); | |
| 86 | + server.once("error", () => resume(Effect.succeed({ baseUrl: "", close }))); | |
| 87 | + server.listen(0, "127.0.0.1", () => { | |
| 88 | + const address = server.address(); | |
| 89 | + const port = typeof address === "object" && address ? address.port : 0; | |
| 90 | + resume(Effect.succeed({ baseUrl: `http://127.0.0.1:${port}`, close })); | |
| 91 | + }); | |
| 92 | + }), | |
| 93 | + (acquired) => acquired.close, | |
| 94 | + ).pipe(Effect.map(({ close: _close, ...rest }) => rest)); | |
| 95 | + | |
| 96 | +// Inline spec: one GET (read) + one POST (write) operation on /thing, both | |
| 97 | +// secured by the same bearer scheme. operationIds are single identifier-safe | |
| 98 | +// words so the derived tool names are predictable, but the test discovers the | |
| 99 | +// real tool addresses from the catalog rather than hardcoding them. | |
| 100 | +const makeSpec = (): string => | |
| 101 | + JSON.stringify({ | |
| 102 | + openapi: "3.0.3", | |
| 103 | + info: { title: "Read-mode API", version: "1.0.0" }, | |
| 104 | + paths: { | |
| 105 | + "/thing": { | |
| 106 | + get: { | |
| 107 | + operationId: "getthing", | |
| 108 | + summary: "Read a thing", | |
| 109 | + responses: { "200": { description: "the thing" } }, | |
| 110 | + }, | |
| 111 | + post: { | |
| 112 | + operationId: "creatething", | |
| 113 | + summary: "Create a thing", | |
| 114 | + responses: { "201": { description: "created" } }, | |
| 115 | + }, | |
| 116 | + }, | |
| 117 | + }, | |
| 118 | + }); | |
| 119 | + | |
| 120 | +scenario( | |
| 121 | + 'Toolkits · access "read" exposes only read-only (GET) tools and blocks writes (POST)', | |
| 122 | + { timeout: 180_000 }, | |
| 123 | + Effect.scoped( | |
| 124 | + Effect.gen(function* () { | |
| 125 | + const target = yield* Target; | |
| 126 | + const { client: makeApiClient } = yield* Api; | |
| 127 | + const mcp = yield* Mcp; | |
| 128 | + const identity = yield* target.newIdentity(); | |
| 129 | + const client = yield* makeApiClient(api, identity); | |
| 130 | + | |
| 131 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 132 | + const upstream = yield* serveUpstream(token); | |
| 133 | + expect(upstream.baseUrl, "the loopback OpenAPI upstream bound a port").not.toBe(""); | |
| 134 | + | |
| 135 | + const slug = ident("oapi"); | |
| 136 | + const conn = ident("conn"); | |
| 137 | + | |
| 138 | + // Register the integration (spec inline; baseUrl points at the real | |
| 139 | + // loopback socket) and create the org connection that holds the bearer. | |
| 140 | + yield* client.openapi.addSpec({ | |
| 141 | + payload: { | |
| 142 | + spec: { kind: "blob", value: makeSpec() }, | |
| 143 | + slug, | |
| 144 | + baseUrl: upstream.baseUrl, | |
| 145 | + authenticationTemplate: [ | |
| 146 | + { | |
| 147 | + slug: "apiKey", | |
| 148 | + type: "apiKey", | |
| 149 | + headers: { | |
| 150 | + authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 151 | + }, | |
| 152 | + }, | |
| 153 | + ], | |
| 154 | + }, | |
| 155 | + }); | |
| 156 | + yield* client.connections.create({ | |
| 157 | + payload: { | |
| 158 | + owner: "org", | |
| 159 | + name: ConnectionName.make(conn), | |
| 160 | + integration: IntegrationSlug.make(slug), | |
| 161 | + template: AuthTemplateSlug.make("apiKey"), | |
| 162 | + value: token, | |
| 163 | + }, | |
| 164 | + }); | |
| 165 | + | |
| 166 | + // Discover the real tool addresses from the catalog. The GET-derived tool | |
| 167 | + // is read-only (requiresApproval falsy); the POST-derived tool is a write | |
| 168 | + // (requiresApproval true, per annotationsForOperation). We derive the | |
| 169 | + // sandbox call name (the last dotted segment of the address) so we never | |
| 170 | + // hardcode the camelCase leaf the definition layer produces. | |
| 171 | + const tools = yield* client.tools.list({ | |
| 172 | + query: { integration: IntegrationSlug.make(slug) }, | |
| 173 | + }); | |
| 174 | + const mine = tools.filter((t) => String(t.integration) === slug); | |
| 175 | + const readTool = mine.find((t) => t.requiresApproval !== true); | |
| 176 | + const writeTool = mine.find((t) => t.requiresApproval === true); | |
| 177 | + expect( | |
| 178 | + readTool?.address, | |
| 179 | + `GET-derived read-only tool is in the catalog; tools=${mine | |
| 180 | + .map((t) => `${t.name}(approval=${String(t.requiresApproval)})`) | |
| 181 | + .join(", ")}`, | |
| 182 | + ).toBeDefined(); | |
| 183 | + expect( | |
| 184 | + writeTool?.address, | |
| 185 | + `POST-derived write tool is in the catalog; tools=${mine | |
| 186 | + .map((t) => `${t.name}(approval=${String(t.requiresApproval)})`) | |
| 187 | + .join(", ")}`, | |
| 188 | + ).toBeDefined(); | |
| 189 | + const readName = String(readTool!.name); | |
| 190 | + const writeName = String(writeTool!.name); | |
| 191 | + | |
| 192 | + // Two workspace toolkits over the SAME connection: one at "read", one at | |
| 193 | + // "full". | |
| 194 | + const kitRead = yield* client.toolkits.create({ | |
| 195 | + payload: { | |
| 196 | + slug: ident("kitread"), | |
| 197 | + name: "Read kit", | |
| 198 | + scope: "workspace", | |
| 199 | + connections: [ | |
| 200 | + { | |
| 201 | + integration: IntegrationSlug.make(slug), | |
| 202 | + connection: conn, | |
| 203 | + access: "read", | |
| 204 | + }, | |
| 205 | + ], | |
| 206 | + }, | |
| 207 | + }); | |
| 208 | + const kitFull = yield* client.toolkits.create({ | |
| 209 | + payload: { | |
| 210 | + slug: ident("kitfull"), | |
| 211 | + name: "Full kit", | |
| 212 | + scope: "workspace", | |
| 213 | + connections: [ | |
| 214 | + { | |
| 215 | + integration: IntegrationSlug.make(slug), | |
| 216 | + connection: conn, | |
| 217 | + access: "full", | |
| 218 | + }, | |
| 219 | + ], | |
| 220 | + }, | |
| 221 | + }); | |
| 222 | + | |
| 223 | + // --- READ toolkit: GET runs, POST is blocked --------------------------- | |
| 224 | + const scopedRead = mcp.session(identity, { toolkit: kitRead.slug }); | |
| 225 | + | |
| 226 | + // The scoped inventory still lists the integration (it is in the slice), | |
| 227 | + // and the read tool's dotted name is reachable. | |
| 228 | + const readDesc = describeExecute(yield* scopedRead.describeTools()); | |
| 229 | + expect(readDesc, "read-toolkit inventory includes the in-slice integration").toContain(slug); | |
| 230 | + | |
| 231 | + const readGet = yield* scopedRead.call("execute", { | |
| 232 | + code: `return await tools.${slug}.org.${conn}.${readName}({});`, | |
| 233 | + }); | |
| 234 | + expect( | |
| 235 | + readGet.ok && readGet.text.includes("read-ok"), | |
| 236 | + `read access runs the GET tool; ok=${readGet.ok} text=${readGet.text}`, | |
| 237 | + ).toBe(true); | |
| 238 | + | |
| 239 | + const readPost = yield* scopedRead.call("execute", { | |
| 240 | + code: `return await tools.${slug}.org.${conn}.${writeName}({ body: {} });`, | |
| 241 | + }); | |
| 242 | + // Under "read" access the write (POST) tool is removed from the slice, so | |
| 243 | + // it is not reachable in the sandbox — the call neither completes (no | |
| 244 | + // upstream "write-ok") nor even pauses for approval ("paused"); it errors. | |
| 245 | + expect( | |
| 246 | + readPost.text.includes("write-ok") || readPost.text.includes("paused"), | |
| 247 | + `read access must EXCLUDE the write tool (unreachable); text=${readPost.text}`, | |
| 248 | + ).toBe(false); | |
| 249 | + expect( | |
| 250 | + readPost.text, | |
| 251 | + `excluded write errors, not the GET payload; text=${readPost.text}`, | |
| 252 | + ).not.toBe(readGet.text); | |
| 253 | + | |
| 254 | + // --- FULL toolkit: GET runs; POST is reachable (pauses for approval) ---- | |
| 255 | + const scopedFull = mcp.session(identity, { toolkit: kitFull.slug }); | |
| 256 | + | |
| 257 | + const fullDesc = describeExecute(yield* scopedFull.describeTools()); | |
| 258 | + expect(fullDesc, "full-toolkit inventory includes the integration").toContain(slug); | |
| 259 | + | |
| 260 | + const fullGet = yield* scopedFull.call("execute", { | |
| 261 | + code: `return await tools.${slug}.org.${conn}.${readName}({});`, | |
| 262 | + }); | |
| 263 | + expect( | |
| 264 | + fullGet.ok && fullGet.text.includes("read-ok"), | |
| 265 | + `full access runs the GET tool; ok=${fullGet.ok} text=${fullGet.text}`, | |
| 266 | + ).toBe(true); | |
| 267 | + | |
| 268 | + const fullPost = yield* scopedFull.call("execute", { | |
| 269 | + code: `return await tools.${slug}.org.${conn}.${writeName}({ body: {} });`, | |
| 270 | + }); | |
| 271 | + // Under "full" access the write tool IS in the slice. Writes still require | |
| 272 | + // approval (independent of toolkit access), so a default-mode session | |
| 273 | + // pauses ("Execution paused") rather than completing — which still proves | |
| 274 | + // the tool is REACHABLE, the opposite of read access where it is excluded. | |
| 275 | + expect( | |
| 276 | + fullPost.text.includes("paused") || fullPost.text.includes("write-ok"), | |
| 277 | + `full access EXPOSES the write tool (runs or pauses for approval); text=${fullPost.text}`, | |
| 278 | + ).toBe(true); | |
| 279 | + expect( | |
| 280 | + fullPost.text, | |
| 281 | + `full reaches the write tool while read excludes it; read=${readPost.text} full=${fullPost.text}`, | |
| 282 | + ).not.toBe(readPost.text); | |
| 283 | + }), | |
| 284 | + ), | |
| 285 | +); | |
| @@ -0,0 +1,178 @@ | ||
| 1 | +// Selfhost · Slice 2 (console UI): the Toolkits page renders through the real | |
| 2 | +// web console and its editor writes a toolkit — access modes, per-connection | |
| 3 | +// notes, and all — back to the toolkits API. This is the end-to-end proof of | |
| 4 | +// the client plugin wiring: the page only mounts if the Vite plugin bundled | |
| 5 | +// `@executor-js/plugin-toolkits/client`, the nav resolved, and the typed atom | |
| 6 | +// client reached `/api/toolkits`. | |
| 7 | +// | |
| 8 | +// We drive the real controls (the off/read/full segment + the note field), | |
| 9 | +// create the toolkit, then read it back through the typed API and assert the | |
| 10 | +// persisted slice — so the editor's access-map -> wire-entry transform (incl. | |
| 11 | +// note trimming) is covered through the UI, not a unit test. The personal | |
| 12 | +// connection we seed is owner-scoped to this fresh identity, so it is a | |
| 13 | +// deterministic anchor even though selfhost identities share one tenant. | |
| 14 | +import { randomBytes } from "node:crypto"; | |
| 15 | + | |
| 16 | +import { expect } from "@effect/vitest"; | |
| 17 | +import { Effect } from "effect"; | |
| 18 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 19 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 20 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 21 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 22 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 23 | + | |
| 24 | +import { scenario } from "../src/scenario"; | |
| 25 | +import { Api, Browser, Target } from "../src/services"; | |
| 26 | + | |
| 27 | +const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const); | |
| 28 | +const fresh = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 29 | + | |
| 30 | +scenario( | |
| 31 | + "Toolkits · the console editor creates a toolkit and persists its access slice", | |
| 32 | + { timeout: 180_000 }, | |
| 33 | + Effect.scoped( | |
| 34 | + Effect.gen(function* () { | |
| 35 | + const target = yield* Target; | |
| 36 | + const { client: makeApiClient } = yield* Api; | |
| 37 | + const browser = yield* Browser; | |
| 38 | + const identity = yield* target.newIdentity(); | |
| 39 | + const client = yield* makeApiClient(api, identity); | |
| 40 | + | |
| 41 | + // Seed a PERSONAL connection (owner "user") so it shows only for this | |
| 42 | + // identity — the editor's Access section should surface it. | |
| 43 | + const slug = fresh("tkui"); | |
| 44 | + const connName = fresh("conn"); | |
| 45 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 46 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 47 | + auth: { | |
| 48 | + validateAuthorization: (authorization) => | |
| 49 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 50 | + }, | |
| 51 | + }); | |
| 52 | + yield* client.mcp.addServer({ | |
| 53 | + payload: { | |
| 54 | + transport: "remote", | |
| 55 | + name: `Greeting ${slug}`, | |
| 56 | + endpoint: server.endpoint, | |
| 57 | + slug, | |
| 58 | + authenticationTemplate: [ | |
| 59 | + { | |
| 60 | + type: "apiKey", | |
| 61 | + headers: { | |
| 62 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 63 | + }, | |
| 64 | + }, | |
| 65 | + ], | |
| 66 | + }, | |
| 67 | + }); | |
| 68 | + yield* client.connections.create({ | |
| 69 | + payload: { | |
| 70 | + owner: "user", | |
| 71 | + name: ConnectionName.make(connName), | |
| 72 | + integration: IntegrationSlug.make(slug), | |
| 73 | + template: AuthTemplateSlug.make("header"), | |
| 74 | + value: token, | |
| 75 | + }, | |
| 76 | + }); | |
| 77 | + | |
| 78 | + const toolkitName = `Kit ${randomBytes(3).toString("hex")}`; | |
| 79 | + const connTestId = `tk-conn ${slug} ${connName}`; | |
| 80 | + | |
| 81 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 82 | + await step("Open the Toolkits page", async () => { | |
| 83 | + await page.goto("/plugins/toolkits/", { waitUntil: "networkidle" }); | |
| 84 | + await page.getByRole("heading", { name: "Toolkits", exact: true }).waitFor(); | |
| 85 | + await page.getByRole("heading", { name: "Workspace toolkits" }).waitFor(); | |
| 86 | + await page.getByRole("heading", { name: "Personal toolkits" }).waitFor(); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + await step("Start a new personal toolkit", async () => { | |
| 90 | + // Two "New toolkit" buttons (workspace, personal); the second is the | |
| 91 | + // personal section. | |
| 92 | + await page.getByRole("button", { name: "New toolkit" }).nth(1).click(); | |
| 93 | + await page.getByRole("heading", { name: "Access" }).waitFor(); | |
| 94 | + // The personal connection appears (proves the personal scope tier + | |
| 95 | + // connection wiring). | |
| 96 | + await page.getByText(connName).first().waitFor(); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + await step("Name it, grant the connection full access, add a note", async () => { | |
| 100 | + // The title is the first textbox in the editor (briefing is below it). | |
| 101 | + await page.getByRole("textbox").first().fill(toolkitName); | |
| 102 | + // Grant Full on this connection's row (testid keeps it unambiguous). | |
| 103 | + const row = page.getByTestId(connTestId); | |
| 104 | + await row.getByRole("button", { name: "Full", exact: true }).click(); | |
| 105 | + // The note field appears once access != off; padded to prove trimming. | |
| 106 | + await row.getByRole("textbox").fill(" wiki only "); | |
| 107 | + }); | |
| 108 | + | |
| 109 | + await step("Create it", async () => { | |
| 110 | + await page.getByRole("button", { name: "Create toolkit" }).click(); | |
| 111 | + // Back on the list, the New toolkit buttons return and the card shows. | |
| 112 | + await page.getByRole("button", { name: "New toolkit" }).first().waitFor(); | |
| 113 | + await page.getByText(toolkitName).first().waitFor(); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + let toolkitId = ""; | |
| 117 | + | |
| 118 | + await step("Opening a toolkit card updates the URL to its id", async () => { | |
| 119 | + await page.getByText(toolkitName).first().click(); | |
| 120 | + await page.getByRole("heading", { name: "Access" }).waitFor(); | |
| 121 | + await page.waitForURL((url) => { | |
| 122 | + const segments = url.pathname.split("/").filter(Boolean); | |
| 123 | + const last = segments[segments.length - 1]; | |
| 124 | + return last !== undefined && last !== "toolkits"; | |
| 125 | + }); | |
| 126 | + toolkitId = new URL(page.url()).pathname.split("/").filter(Boolean).pop() ?? ""; | |
| 127 | + expect(toolkitId.length, "card open exposes the toolkit id in the URL").toBeGreaterThan( | |
| 128 | + 0, | |
| 129 | + ); | |
| 130 | + await page.getByRole("button", { name: "← Toolkits" }).click(); | |
| 131 | + await page.getByRole("heading", { name: "Workspace toolkits" }).waitFor(); | |
| 132 | + }); | |
| 133 | + | |
| 134 | + await step("Deep-linking to a toolkit opens the editor with its name", async () => { | |
| 135 | + await page.goto(`/plugins/toolkits/${toolkitId}`, { | |
| 136 | + waitUntil: "networkidle", | |
| 137 | + }); | |
| 138 | + await page.getByRole("heading", { name: "Access" }).waitFor(); | |
| 139 | + expect( | |
| 140 | + await page.getByRole("textbox").first().inputValue(), | |
| 141 | + "deep link opens the editor with the toolkit name", | |
| 142 | + ).toBe(toolkitName); | |
| 143 | + }); | |
| 144 | + | |
| 145 | + await step("Browser back returns to the toolkit list", async () => { | |
| 146 | + await page.goBack({ waitUntil: "networkidle" }); | |
| 147 | + await page.waitForURL( | |
| 148 | + (url) => | |
| 149 | + url.pathname.endsWith("/plugins/toolkits/") || | |
| 150 | + url.pathname.endsWith("/plugins/toolkits"), | |
| 151 | + ); | |
| 152 | + await page.getByRole("heading", { name: "Workspace toolkits" }).waitFor(); | |
| 153 | + }); | |
| 154 | + | |
| 155 | + await step("New toolkit navigates to /new/workspace", async () => { | |
| 156 | + await page.getByRole("button", { name: "New toolkit" }).first().click(); | |
| 157 | + await page.waitForURL((url) => url.pathname.endsWith("/new/workspace")); | |
| 158 | + await page.getByRole("heading", { name: "Access" }).waitFor(); | |
| 159 | + }); | |
| 160 | + }); | |
| 161 | + | |
| 162 | + // Read the toolkit back through the typed API: the editor's write carried | |
| 163 | + // the scope, access mode, and trimmed note. | |
| 164 | + const all = yield* client.toolkits.list(); | |
| 165 | + const created = all.find((t) => t.name === toolkitName); | |
| 166 | + expect(created, "the toolkit the UI created is persisted").toBeDefined(); | |
| 167 | + expect(created?.scope).toBe("personal"); | |
| 168 | + expect(created?.connections).toEqual([ | |
| 169 | + { | |
| 170 | + integration: slug, | |
| 171 | + connection: connName, | |
| 172 | + access: "full", | |
| 173 | + note: "wiki only", | |
| 174 | + }, | |
| 175 | + ]); | |
| 176 | + }), | |
| 177 | + ), | |
| 178 | +); | |
| @@ -0,0 +1,209 @@ | ||
| 1 | +// Selfhost · Slice 3 (live re-narrowing): updating a toolkit's connection set | |
| 2 | +// changes what a NEWLY-opened scoped MCP session sees. `connections` on the | |
| 3 | +// update payload is a FULL REPLACEMENT (server.ts update(): when | |
| 4 | +// patch.connections is defined it deletes every existing connection row and | |
| 5 | +// re-inserts the patch list), so to drop a connection you OMIT it. A session | |
| 6 | +// opened BEFORE an update keeps its original slice — each transition is proven | |
| 7 | +// against a FRESH mcp.session, and read back via toolkits.get to confirm the | |
| 8 | +// persisted set matches. | |
| 9 | +import { randomBytes } from "node:crypto"; | |
| 10 | + | |
| 11 | +import { expect } from "@effect/vitest"; | |
| 12 | +import { Effect } from "effect"; | |
| 13 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 14 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 15 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 16 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 17 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 18 | + | |
| 19 | +import { scenario } from "../src/scenario"; | |
| 20 | +import { Api, Mcp, Target } from "../src/services"; | |
| 21 | + | |
| 22 | +const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const); | |
| 23 | +// Identifier-safe (no hyphens) so the sandbox `tools.<int>.<owner>.<conn>.<tool>` | |
| 24 | +// dotted path stays valid JS; also satisfies the create-time name normalization. | |
| 25 | +const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 26 | + | |
| 27 | +const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string => | |
| 28 | + defs.find((d) => d.name === "execute")?.description ?? ""; | |
| 29 | + | |
| 30 | +scenario( | |
| 31 | + "Toolkits · updating a toolkit's connections re-narrows what a fresh MCP session sees", | |
| 32 | + { timeout: 180_000 }, | |
| 33 | + Effect.scoped( | |
| 34 | + Effect.gen(function* () { | |
| 35 | + const target = yield* Target; | |
| 36 | + const { client: makeApiClient } = yield* Api; | |
| 37 | + const mcp = yield* Mcp; | |
| 38 | + const identity = yield* target.newIdentity(); | |
| 39 | + const client = yield* makeApiClient(api, identity); | |
| 40 | + | |
| 41 | + // Two real MCP greeting servers -> two distinct integrations + org | |
| 42 | + // connections (each exposes a `simple_echo` tool discovered at create). | |
| 43 | + const addConnection = (slug: string, conn: string) => | |
| 44 | + Effect.gen(function* () { | |
| 45 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 46 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 47 | + auth: { | |
| 48 | + validateAuthorization: (authorization) => | |
| 49 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 50 | + }, | |
| 51 | + }); | |
| 52 | + yield* client.mcp.addServer({ | |
| 53 | + payload: { | |
| 54 | + transport: "remote", | |
| 55 | + name: `Greeting ${slug}`, | |
| 56 | + endpoint: server.endpoint, | |
| 57 | + slug, | |
| 58 | + authenticationTemplate: [ | |
| 59 | + { | |
| 60 | + type: "apiKey", | |
| 61 | + headers: { | |
| 62 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 63 | + }, | |
| 64 | + }, | |
| 65 | + ], | |
| 66 | + }, | |
| 67 | + }); | |
| 68 | + yield* client.connections.create({ | |
| 69 | + payload: { | |
| 70 | + owner: "org", | |
| 71 | + name: ConnectionName.make(conn), | |
| 72 | + integration: IntegrationSlug.make(slug), | |
| 73 | + template: AuthTemplateSlug.make("header"), | |
| 74 | + value: token, | |
| 75 | + }, | |
| 76 | + }); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + // A = the first integration/connection, B = the second. | |
| 80 | + const slugA = ident("tka"); | |
| 81 | + const slugB = ident("tkb"); | |
| 82 | + const connA = ident("conn"); | |
| 83 | + const connB = ident("conn"); | |
| 84 | + yield* addConnection(slugA, connA); | |
| 85 | + yield* addConnection(slugB, connB); | |
| 86 | + | |
| 87 | + const codeA = `return await tools.${slugA}.org.${connA}.simple_echo({});`; | |
| 88 | + const codeB = `return await tools.${slugB}.org.${connB}.simple_echo({});`; | |
| 89 | + | |
| 90 | + // Workspace toolkit with ONLY A at "full". | |
| 91 | + const kit = yield* client.toolkits.create({ | |
| 92 | + payload: { | |
| 93 | + slug: ident("kit"), | |
| 94 | + name: "Evolving kit", | |
| 95 | + scope: "workspace", | |
| 96 | + connections: [ | |
| 97 | + { | |
| 98 | + integration: IntegrationSlug.make(slugA), | |
| 99 | + connection: connA, | |
| 100 | + access: "full", | |
| 101 | + }, | |
| 102 | + ], | |
| 103 | + }, | |
| 104 | + }); | |
| 105 | + | |
| 106 | + // A baseline "what a runnable in-slice greeting looks like" so we can | |
| 107 | + // distinguish a real greeting from a blocked error envelope (blocked | |
| 108 | + // tools surface as an error in .text, never the greeting). | |
| 109 | + // ---- Session #1: only A in slice ------------------------------------ | |
| 110 | + const s1 = mcp.session(identity, { toolkit: kit.slug }); | |
| 111 | + const desc1 = describeExecute(yield* s1.describeTools()); | |
| 112 | + expect(desc1, "#1 inventory includes A").toContain(slugA); | |
| 113 | + expect(desc1, "#1 inventory omits B").not.toContain(slugB); | |
| 114 | + | |
| 115 | + const s1A = yield* s1.call("execute", { code: codeA }); | |
| 116 | + expect(s1A.ok, `#1 A executes; text=${s1A.text}`).toBe(true); | |
| 117 | + const greeting = s1A.text; // the canonical success text for simple_echo | |
| 118 | + | |
| 119 | + const s1B = yield* s1.call("execute", { code: codeB }); | |
| 120 | + expect( | |
| 121 | + s1B.text, | |
| 122 | + `#1 B must be blocked (not the greeting); B.ok=${s1B.ok} B.text=${s1B.text}`, | |
| 123 | + ).not.toBe(greeting); | |
| 124 | + | |
| 125 | + // Persisted set after create: exactly A. | |
| 126 | + const view1 = yield* client.toolkits.get({ params: { id: kit.id } }); | |
| 127 | + expect(view1.connections.map((c) => c.integration).sort(), "#1 persisted = [A]").toEqual( | |
| 128 | + [slugA].sort(), | |
| 129 | + ); | |
| 130 | + | |
| 131 | + // ---- Update -> [A full, B full]; Session #2 (fresh): BOTH present ---- | |
| 132 | + const upd2 = yield* client.toolkits.update({ | |
| 133 | + params: { id: kit.id }, | |
| 134 | + payload: { | |
| 135 | + connections: [ | |
| 136 | + { | |
| 137 | + integration: IntegrationSlug.make(slugA), | |
| 138 | + connection: connA, | |
| 139 | + access: "full", | |
| 140 | + }, | |
| 141 | + { | |
| 142 | + integration: IntegrationSlug.make(slugB), | |
| 143 | + connection: connB, | |
| 144 | + access: "full", | |
| 145 | + }, | |
| 146 | + ], | |
| 147 | + }, | |
| 148 | + }); | |
| 149 | + expect(upd2.connections.map((c) => c.integration).sort(), "update#2 echoes [A,B]").toEqual( | |
| 150 | + [slugA, slugB].sort(), | |
| 151 | + ); | |
| 152 | + | |
| 153 | + const view2 = yield* client.toolkits.get({ params: { id: kit.id } }); | |
| 154 | + expect(view2.connections.map((c) => c.integration).sort(), "#2 persisted = [A,B]").toEqual( | |
| 155 | + [slugA, slugB].sort(), | |
| 156 | + ); | |
| 157 | + | |
| 158 | + const s2 = mcp.session(identity, { toolkit: kit.slug }); | |
| 159 | + const desc2 = describeExecute(yield* s2.describeTools()); | |
| 160 | + expect(desc2, "#2 inventory includes A").toContain(slugA); | |
| 161 | + expect(desc2, "#2 inventory now includes B").toContain(slugB); | |
| 162 | + | |
| 163 | + const s2A = yield* s2.call("execute", { code: codeA }); | |
| 164 | + expect(s2A.ok, `#2 A still runnable; text=${s2A.text}`).toBe(true); | |
| 165 | + const s2B = yield* s2.call("execute", { code: codeB }); | |
| 166 | + expect(s2B.ok, `#2 B now runnable; text=${s2B.text}`).toBe(true); | |
| 167 | + expect(s2B.text, "#2 B returns the greeting, not an error").toBe(greeting); | |
| 168 | + | |
| 169 | + // ---- Update -> [B full] only (A dropped == off); Session #3 fresh --- | |
| 170 | + const upd3 = yield* client.toolkits.update({ | |
| 171 | + params: { id: kit.id }, | |
| 172 | + payload: { | |
| 173 | + connections: [ | |
| 174 | + { | |
| 175 | + integration: IntegrationSlug.make(slugB), | |
| 176 | + connection: connB, | |
| 177 | + access: "full", | |
| 178 | + }, | |
| 179 | + ], | |
| 180 | + }, | |
| 181 | + }); | |
| 182 | + expect( | |
| 183 | + upd3.connections.map((c) => c.integration), | |
| 184 | + "update#3 echoes [B] only — full replacement dropped A", | |
| 185 | + ).toEqual([slugB]); | |
| 186 | + | |
| 187 | + const view3 = yield* client.toolkits.get({ params: { id: kit.id } }); | |
| 188 | + expect( | |
| 189 | + view3.connections.map((c) => c.integration), | |
| 190 | + "#3 persisted = [B] only", | |
| 191 | + ).toEqual([slugB]); | |
| 192 | + | |
| 193 | + const s3 = mcp.session(identity, { toolkit: kit.slug }); | |
| 194 | + const desc3 = describeExecute(yield* s3.describeTools()); | |
| 195 | + expect(desc3, "#3 inventory includes B").toContain(slugB); | |
| 196 | + expect(desc3, "#3 inventory now omits A").not.toContain(slugA); | |
| 197 | + | |
| 198 | + const s3B = yield* s3.call("execute", { code: codeB }); | |
| 199 | + expect(s3B.ok, `#3 B runnable; text=${s3B.text}`).toBe(true); | |
| 200 | + const s3A = yield* s3.call("execute", { code: codeA }); | |
| 201 | + // A is out-of-slice again -> blocked at execute (error envelope in .text), | |
| 202 | + // never the greeting the in-slice call returns. | |
| 203 | + expect( | |
| 204 | + s3A.text, | |
| 205 | + `#3 A must be blocked after being dropped; A.ok=${s3A.ok} A.text=${s3A.text}`, | |
| 206 | + ).not.toBe(greeting); | |
| 207 | + }), | |
| 208 | + ), | |
| 209 | +); | |
| @@ -0,0 +1,173 @@ | ||
| 1 | +// Selfhost · Toolkits wildcard ("*") tracks every account of an integration, | |
| 2 | +// including accounts added AFTER the toolkit is created. A toolkit entry whose | |
| 3 | +// `connection` is "*" is integration-level: the MCP narrowing seam | |
| 4 | +// (toolkit-scope.ts `accessFor`) matches ANY connection of that integration, | |
| 5 | +// and `resolveScope` re-reads the live connection set per session — so a fresh | |
| 6 | +// scoped session opened after a new account is added auto-includes it, while a | |
| 7 | +// session's slice is fixed to the integration, never leaking a sibling | |
| 8 | +// integration's accounts. | |
| 9 | +import { randomBytes } from "node:crypto"; | |
| 10 | + | |
| 11 | +import { expect } from "@effect/vitest"; | |
| 12 | +import { Effect } from "effect"; | |
| 13 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 14 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 15 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 16 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 17 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 18 | + | |
| 19 | +import { scenario } from "../src/scenario"; | |
| 20 | +import { Api, Mcp, Target } from "../src/services"; | |
| 21 | + | |
| 22 | +const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const); | |
| 23 | +// Identifier-safe (no hyphens) so the sandbox `tools.<int>.<owner>.<conn>.<tool>` | |
| 24 | +// dotted path stays valid JS, and single-word lowercase names survive the | |
| 25 | +// connectionIdentifier normalization unchanged. | |
| 26 | +const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`; | |
| 27 | + | |
| 28 | +const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string => | |
| 29 | + defs.find((d) => d.name === "execute")?.description ?? ""; | |
| 30 | + | |
| 31 | +scenario( | |
| 32 | + 'Toolkits · a wildcard ("*") entry tracks every account of an integration, including ones added after the toolkit', | |
| 33 | + { timeout: 180_000 }, | |
| 34 | + Effect.scoped( | |
| 35 | + Effect.gen(function* () { | |
| 36 | + const target = yield* Target; | |
| 37 | + const { client: makeApiClient } = yield* Api; | |
| 38 | + const mcp = yield* Mcp; | |
| 39 | + const identity = yield* target.newIdentity(); | |
| 40 | + const client = yield* makeApiClient(api, identity); | |
| 41 | + | |
| 42 | + // Register an integration ONCE via addServer, then add accounts to it with | |
| 43 | + // repeated connections.create calls (same integration slug, new name). | |
| 44 | + // addServer creates/updates the integration + auth template; each | |
| 45 | + // connections.create adds one account (org-owned connection) under it. | |
| 46 | + const addIntegration = (slug: string) => | |
| 47 | + Effect.gen(function* () { | |
| 48 | + const token = `tok-${randomBytes(6).toString("hex")}`; | |
| 49 | + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | |
| 50 | + auth: { | |
| 51 | + validateAuthorization: (authorization) => | |
| 52 | + Effect.succeed(authorization === `Bearer ${token}`), | |
| 53 | + }, | |
| 54 | + }); | |
| 55 | + yield* client.mcp.addServer({ | |
| 56 | + payload: { | |
| 57 | + transport: "remote", | |
| 58 | + name: `Greeting ${slug}`, | |
| 59 | + endpoint: server.endpoint, | |
| 60 | + slug, | |
| 61 | + authenticationTemplate: [ | |
| 62 | + { | |
| 63 | + type: "apiKey", | |
| 64 | + headers: { | |
| 65 | + Authorization: ["Bearer ", { type: "variable", name: "token" }], | |
| 66 | + }, | |
| 67 | + }, | |
| 68 | + ], | |
| 69 | + }, | |
| 70 | + }); | |
| 71 | + // Each account points at the same integration's greeting server; the | |
| 72 | + // token satisfies that integration's auth template. | |
| 73 | + const addAccount = (conn: string) => | |
| 74 | + client.connections.create({ | |
| 75 | + payload: { | |
| 76 | + owner: "org", | |
| 77 | + name: ConnectionName.make(conn), | |
| 78 | + integration: IntegrationSlug.make(slug), | |
| 79 | + template: AuthTemplateSlug.make("header"), | |
| 80 | + value: token, | |
| 81 | + }, | |
| 82 | + }); | |
| 83 | + return { addAccount }; | |
| 84 | + }); | |
| 85 | + | |
| 86 | + const slugX = ident("tkx"); | |
| 87 | + const { addAccount: addX } = yield* addIntegration(slugX); | |
| 88 | + | |
| 89 | + // Integration X starts with TWO org accounts. | |
| 90 | + const primary = ident("primary"); | |
| 91 | + const secondary = ident("secondary"); | |
| 92 | + yield* addX(primary); | |
| 93 | + yield* addX(secondary); | |
| 94 | + | |
| 95 | + // An UNRELATED integration Y with its own account — the wildcard must NOT | |
| 96 | + // reach it (the entry is scoped to X only). | |
| 97 | + const slugY = ident("tky"); | |
| 98 | + const { addAccount: addY } = yield* addIntegration(slugY); | |
| 99 | + const connY = ident("other"); | |
| 100 | + yield* addY(connY); | |
| 101 | + | |
| 102 | + // A workspace toolkit with a SINGLE integration-level entry: every account | |
| 103 | + // of X, full access. Y is never named, so it is out of slice. | |
| 104 | + const kit = yield* client.toolkits.create({ | |
| 105 | + payload: { | |
| 106 | + slug: ident("kit"), | |
| 107 | + name: "Wildcard kit", | |
| 108 | + scope: "workspace", | |
| 109 | + connections: [ | |
| 110 | + { | |
| 111 | + integration: IntegrationSlug.make(slugX), | |
| 112 | + connection: "*", | |
| 113 | + access: "full", | |
| 114 | + }, | |
| 115 | + ], | |
| 116 | + }, | |
| 117 | + }); | |
| 118 | + | |
| 119 | + // Session #1: both pre-existing accounts are in the slice; Y is not. | |
| 120 | + const session1 = mcp.session(identity, { toolkit: kit.slug }); | |
| 121 | + const desc1 = describeExecute(yield* session1.describeTools()); | |
| 122 | + expect(desc1, "wildcard slice includes the primary account").toContain(primary); | |
| 123 | + expect(desc1, "wildcard slice includes the secondary account").toContain(secondary); | |
| 124 | + expect(desc1, "wildcard is scoped to X — Y's account is out of slice").not.toContain(connY); | |
| 125 | + | |
| 126 | + const primaryRun = yield* session1.call("execute", { | |
| 127 | + code: `return await tools.${slugX}.org.${primary}.simple_echo({});`, | |
| 128 | + }); | |
| 129 | + expect(primaryRun.ok, `primary account runs; text=${primaryRun.text}`).toBe(true); | |
| 130 | + | |
| 131 | + const secondaryRun = yield* session1.call("execute", { | |
| 132 | + code: `return await tools.${slugX}.org.${secondary}.simple_echo({});`, | |
| 133 | + }); | |
| 134 | + expect(secondaryRun.ok, `secondary account runs; text=${secondaryRun.text}`).toBe(true); | |
| 135 | + | |
| 136 | + // Y is blocked at execute even though the agent guessed its address — a | |
| 137 | + // blocked tool surfaces as an error envelope, never the greeting a | |
| 138 | + // legitimate account returns. | |
| 139 | + const yRun = yield* session1.call("execute", { | |
| 140 | + code: `return await tools.${slugY}.org.${connY}.simple_echo({});`, | |
| 141 | + }); | |
| 142 | + expect( | |
| 143 | + yRun.text, | |
| 144 | + `out-of-slice Y must be blocked; primary.text=${primaryRun.text} y.ok=${yRun.ok} y.text=${yRun.text}`, | |
| 145 | + ).not.toBe(primaryRun.text); | |
| 146 | + | |
| 147 | + // --- The auto-update assertion ---------------------------------------- | |
| 148 | + // Add a THIRD account to X AFTER the toolkit was created. The toolkit is | |
| 149 | + // untouched (still a single "*" entry). | |
| 150 | + const tertiary = ident("tertiary"); | |
| 151 | + yield* addX(tertiary); | |
| 152 | + | |
| 153 | + // A NEW scoped session re-resolves the slice, so the wildcard now also | |
| 154 | + // covers the just-added account. (Session #1 keeps its original snapshot — | |
| 155 | + // we deliberately open a fresh session here.) | |
| 156 | + const session2 = mcp.session(identity, { toolkit: kit.slug }); | |
| 157 | + const desc2 = describeExecute(yield* session2.describeTools()); | |
| 158 | + expect(desc2, "a fresh session's wildcard still includes primary").toContain(primary); | |
| 159 | + expect(desc2, "a fresh session's wildcard still includes secondary").toContain(secondary); | |
| 160 | + expect(desc2, "the wildcard auto-includes the account added after the toolkit").toContain( | |
| 161 | + tertiary, | |
| 162 | + ); | |
| 163 | + | |
| 164 | + const tertiaryRun = yield* session2.call("execute", { | |
| 165 | + code: `return await tools.${slugX}.org.${tertiary}.simple_echo({});`, | |
| 166 | + }); | |
| 167 | + expect( | |
| 168 | + tertiaryRun.ok, | |
| 169 | + `the later-added account runs in a fresh scoped session; text=${tertiaryRun.text}`, | |
| 170 | + ).toBe(true); | |
| 171 | + }), | |
| 172 | + ), | |
| 173 | +); | |
| @@ -160,7 +160,7 @@ export interface McpSurface { · 159 unmodified lines | ||
| 160 | 160 | readonly url: string; |
| 161 | 161 | readonly session: ( |
| 162 | 162 | identity: Identity, |
| 163 | − options?: { readonly elicitationMode?: McpElicitationMode }, | |
| 163 | + options?: { readonly elicitationMode?: McpElicitationMode; readonly toolkit?: string }, | |
| 164 | 164 | ) => McpSession; |
| 165 | 165 | /** |
| 166 | 166 | * Mint a real MCP bearer headlessly: protected-resource discovery → |
| @@ -271,9 +271,14 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 104 unmodified lines | ||
| 271 | 271 | // `browser` mode is selected per the ecosystem convention — an |
| 272 | 272 | // `?elicitation_mode=` query on the MCP endpoint — so a paused execution |
| 273 | 273 | // yields an approvalUrl instead of letting the model resume inline. |
| 274 | − const sessionUrl = options?.elicitationMode | |
| 275 | − ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}` | |
| 276 | − : target.mcpUrl; | |
| 274 | + const sessionUrl = (() => { | |
| 275 | + const u = new URL(target.mcpUrl); | |
| 276 | + if (options?.elicitationMode) u.searchParams.set("elicitation_mode", options.elicitationMode); | |
| 277 | + // Toolkit selector — narrows the engine to that toolkit's slice (same path | |
| 278 | + // the host reads via `readToolkitSelector`). | |
| 279 | + if (options?.toolkit) u.searchParams.set("toolkit", options.toolkit); | |
| 280 | + return u.toString(); | |
| 281 | + })(); | |
| 277 | 282 | let runtimePromise: Promise<Runtime> | undefined; |
| 278 | 283 | let connected = false; |
| 279 | 284 | |