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 | "@executor-js/plugin-graphql": "workspace:*", | 337 | "@executor-js/plugin-graphql": "workspace:*", |
| 338 | "@executor-js/plugin-mcp": "workspace:*", | 338 | "@executor-js/plugin-mcp": "workspace:*", |
| 339 | "@executor-js/plugin-openapi": "workspace:*", | 339 | "@executor-js/plugin-openapi": "workspace:*", |
| 340 | "@executor-js/plugin-toolkits": "workspace:*", | ||
| 340 | "@executor-js/sdk": "workspace:*", | 341 | "@executor-js/sdk": "workspace:*", |
| 341 | "@kitlangton/terminal-control": "^0.3.0", | 342 | "@kitlangton/terminal-control": "^0.3.0", |
| 342 | "@modelcontextprotocol/sdk": "^1.29.0", | 343 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -26,6 +26,7 @@ · 25 unmodified lines | |||
| 26 | "@executor-js/plugin-graphql": "workspace:*", | 26 | "@executor-js/plugin-graphql": "workspace:*", |
| 27 | "@executor-js/plugin-mcp": "workspace:*", | 27 | "@executor-js/plugin-mcp": "workspace:*", |
| 28 | "@executor-js/plugin-openapi": "workspace:*", | 28 | "@executor-js/plugin-openapi": "workspace:*", |
| 29 | "@executor-js/plugin-toolkits": "workspace:*", | ||
| 29 | "@executor-js/sdk": "workspace:*", | 30 | "@executor-js/sdk": "workspace:*", |
| 30 | "@kitlangton/terminal-control": "^0.3.0", | 31 | "@kitlangton/terminal-control": "^0.3.0", |
| 31 | "@modelcontextprotocol/sdk": "^1.29.0", | 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 | readonly url: string; | 160 | readonly url: string; |
| 161 | readonly session: ( | 161 | readonly session: ( |
| 162 | identity: Identity, | 162 | identity: Identity, |
| 163 | options?: { readonly elicitationMode?: McpElicitationMode }, | 163 | options?: { readonly elicitationMode?: McpElicitationMode; readonly toolkit?: string }, |
| 164 | ) => McpSession; | 164 | ) => McpSession; |
| 165 | /** | 165 | /** |
| 166 | * Mint a real MCP bearer headlessly: protected-resource discovery → | 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 | // `browser` mode is selected per the ecosystem convention — an | 271 | // `browser` mode is selected per the ecosystem convention — an |
| 272 | // `?elicitation_mode=` query on the MCP endpoint — so a paused execution | 272 | // `?elicitation_mode=` query on the MCP endpoint — so a paused execution |
| 273 | // yields an approvalUrl instead of letting the model resume inline. | 273 | // yields an approvalUrl instead of letting the model resume inline. |
| 274 | const sessionUrl = options?.elicitationMode | 274 | const sessionUrl = (() => { |
| 275 | ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}` | 275 | const u = new URL(target.mcpUrl); |
| 276 | : 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 | let runtimePromise: Promise<Runtime> | undefined; | 282 | let runtimePromise: Promise<Runtime> | undefined; |
| 278 | let connected = false; | 283 | let connected = false; |
| 279 | 284 | ||