History

test(toolkits): broad selfhost e2e coverage

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).

Rhys Sullivan committed 4 months ago

Commit 595ddcb1 parent 9ad7a3316 files · +2477 −4Browse files

Unified · Split

16 files
  1. bun.lock +1 −0
  2. e2e/package.json +1 −0
  3. e2e/selfhost/toolkits-core-tools-scope.test.ts +370 −0
  4. e2e/selfhost/toolkits-cross-user.test.ts +216 −0
  5. e2e/selfhost/toolkits-crud.test.ts +105 −0
  6. e2e/selfhost/toolkits-execute-description.test.ts +158 −0
  7. e2e/selfhost/toolkits-failclosed.test.ts +135 −0
  8. e2e/selfhost/toolkits-inherit-org.test.ts +188 −0
  9. e2e/selfhost/toolkits-isolation.test.ts +184 −0
  10. e2e/selfhost/toolkits-mcp.test.ts +130 −0
  11. e2e/selfhost/toolkits-policies.test.ts +135 −0
  12. e2e/selfhost/toolkits-read-mode.test.ts +285 −0
  13. e2e/selfhost/toolkits-ui.test.ts +178 −0
  14. e2e/selfhost/toolkits-update.test.ts +209 −0
  15. e2e/selfhost/toolkits-wildcard.test.ts +173 −0
  16. e2e/src/surfaces/mcp.ts +9 −4
bun.lock+1−0
@@ -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",
e2e/package.json+1−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",
e2e/selfhost/toolkits-core-tools-scope.test.ts+370−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.
6import { randomBytes } from "node:crypto";
7
8import { expect } from "@effect/vitest";
9import { Effect } from "effect";
10import { composePluginApi } from "@executor-js/api/server";
11import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
12import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
13import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
14import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
15
16import { scenario } from "../src/scenario";
17import { Api, Mcp, Target } from "../src/services";
18import type { McpSession } from "../src/surfaces/mcp";
19
20const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const);
21const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
22
23const isBlocked = (text: string): boolean => text.includes("tool_blocked");
24
25const isSuccessfulGreeting = (text: string): boolean => text.includes("Hello from greeting MCP");
26
27const listConnections = (session: McpSession) =>
28 session.call("execute", {
29 code: `
30const list = await tools.executor.coreTools.connections.list({});
31return JSON.stringify(list.ok ? list.data : { error: list.error });
32`,
33 });
34
35const listIntegrations = (session: McpSession) =>
36 session.call("execute", {
37 code: `
38const list = await tools.executor.coreTools.integrations.list({});
39return JSON.stringify(list.ok ? list.data : { error: list.error });
40`,
41 });
42
43scenario(
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
187scenario(
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: `
253const result = await tools.executor.coreTools.connections.create({
254 owner: "org",
255 integration: "${slug}",
256 name: "${newConn}",
257 template: "header",
258 value: "blocked-token",
259});
260return 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: `
270const result = await tools.executor.coreTools.policies.create({
271 owner: "org",
272 pattern: "${slug}.*",
273 action: "block",
274});
275return 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
286scenario(
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);
e2e/selfhost/toolkits-cross-user.test.ts+216−0
@@ -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.)
20import { randomBytes } from "node:crypto";
21
22import { expect } from "@effect/vitest";
23import { Effect } from "effect";
24import { composePluginApi } from "@executor-js/api/server";
25import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
26import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
27import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
28import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
29
30import { scenario } from "../src/scenario";
31import { Api, Mcp, Target } from "../src/services";
32
33const 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.
37const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
38
39const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string =>
40 defs.find((d) => d.name === "execute")?.description ?? "";
41
42scenario(
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);
e2e/selfhost/toolkits-crud.test.ts+105−0
@@ -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.
5import { randomBytes } from "node:crypto";
6
7import { expect } from "@effect/vitest";
8import { Effect } from "effect";
9import { composePluginApi } from "@executor-js/api/server";
10import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
11import { IntegrationSlug } from "@executor-js/sdk/shared";
12
13import { scenario } from "../src/scenario";
14import { Api, Target } from "../src/services";
15
16const api = composePluginApi([toolkitsPlugin()] as const);
17const fresh = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`;
18
19scenario(
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
76scenario(
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);
e2e/selfhost/toolkits-execute-description.test.ts+158−0
@@ -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.
9import { randomBytes } from "node:crypto";
10
11import { expect } from "@effect/vitest";
12import { Effect } from "effect";
13import { composePluginApi } from "@executor-js/api/server";
14import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
15import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
16import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
17import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
18
19import { scenario } from "../src/scenario";
20import { Api, Mcp, Target } from "../src/services";
21
22const 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.
25const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
26
27const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string =>
28 defs.find((d) => d.name === "execute")?.description ?? "";
29
30scenario(
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);
e2e/selfhost/toolkits-failclosed.test.ts+135−0
@@ -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.
14import { randomBytes } from "node:crypto";
15
16import { expect } from "@effect/vitest";
17import { Effect } from "effect";
18import { composePluginApi } from "@executor-js/api/server";
19import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
20import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
21import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
22import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
23
24import { scenario } from "../src/scenario";
25import { Api, Mcp, Target } from "../src/services";
26
27const 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.
30const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
31
32const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string =>
33 defs.find((d) => d.name === "execute")?.description ?? "";
34
35scenario(
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);
e2e/selfhost/toolkits-inherit-org.test.ts+188−0
@@ -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.
5import { randomBytes } from "node:crypto";
6
7import { expect } from "@effect/vitest";
8import { Effect } from "effect";
9import { composePluginApi } from "@executor-js/api/server";
10import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
11import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
12import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
13import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
14
15import { scenario } from "../src/scenario";
16import { 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.
21const 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.
25const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
26
27const 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.
32const isBlocked = (text: string): boolean => text.includes("tool_blocked");
33
34scenario(
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);
e2e/selfhost/toolkits-isolation.test.ts+184−0
@@ -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.
8import { randomBytes } from "node:crypto";
9
10import { expect } from "@effect/vitest";
11import { Effect } from "effect";
12import { composePluginApi } from "@executor-js/api/server";
13import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
14import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
15import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
16import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
17
18import { scenario } from "../src/scenario";
19import { Api, Mcp, Target } from "../src/services";
20
21const 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.
25const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
26
27const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string =>
28 defs.find((d) => d.name === "execute")?.description ?? "";
29
30scenario(
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);
e2e/selfhost/toolkits-mcp.test.ts+130−0
@@ -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.
6import { randomBytes } from "node:crypto";
7
8import { expect } from "@effect/vitest";
9import { Effect } from "effect";
10import { composePluginApi } from "@executor-js/api/server";
11import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
12import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
13import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
14import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
15
16import { scenario } from "../src/scenario";
17import { Api, Mcp, Target } from "../src/services";
18
19const 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.
22const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
23
24const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string =>
25 defs.find((d) => d.name === "execute")?.description ?? "";
26
27scenario(
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);
e2e/selfhost/toolkits-policies.test.ts+135−0
@@ -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.
7import { randomBytes } from "node:crypto";
8
9import { expect } from "@effect/vitest";
10import { Effect } from "effect";
11import { composePluginApi } from "@executor-js/api/server";
12import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
13import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
14import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
15import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
16
17import { scenario } from "../src/scenario";
18import { Api, Mcp, Target } from "../src/services";
19
20const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const);
21const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
22
23scenario(
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);
e2e/selfhost/toolkits-read-mode.test.ts+285−0
@@ -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.
20import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
21import { randomBytes } from "node:crypto";
22
23import { expect } from "@effect/vitest";
24import { Effect, Scope } from "effect";
25import { composePluginApi } from "@executor-js/api/server";
26import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
27import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
28import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
29import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
30
31import { scenario } from "../src/scenario";
32import { Api, Mcp, Target } from "../src/services";
33
34const 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.
39const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
40
41const 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.
48interface 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.
54const 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.
100const 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
120scenario(
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);
e2e/selfhost/toolkits-ui.test.ts+178−0
@@ -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.
14import { randomBytes } from "node:crypto";
15
16import { expect } from "@effect/vitest";
17import { Effect } from "effect";
18import { composePluginApi } from "@executor-js/api/server";
19import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
20import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
21import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
22import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
23
24import { scenario } from "../src/scenario";
25import { Api, Browser, Target } from "../src/services";
26
27const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const);
28const fresh = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
29
30scenario(
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);
e2e/selfhost/toolkits-update.test.ts+209−0
@@ -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.
9import { randomBytes } from "node:crypto";
10
11import { expect } from "@effect/vitest";
12import { Effect } from "effect";
13import { composePluginApi } from "@executor-js/api/server";
14import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
15import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
16import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
17import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
18
19import { scenario } from "../src/scenario";
20import { Api, Mcp, Target } from "../src/services";
21
22const 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.
25const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
26
27const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string =>
28 defs.find((d) => d.name === "execute")?.description ?? "";
29
30scenario(
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);
e2e/selfhost/toolkits-wildcard.test.ts+173−0
@@ -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.
9import { randomBytes } from "node:crypto";
10
11import { expect } from "@effect/vitest";
12import { Effect } from "effect";
13import { composePluginApi } from "@executor-js/api/server";
14import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
15import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
16import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
17import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
18
19import { scenario } from "../src/scenario";
20import { Api, Mcp, Target } from "../src/services";
21
22const 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.
26const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
27
28const describeExecute = (defs: ReadonlyArray<{ name: string; description?: string }>): string =>
29 defs.find((d) => d.name === "execute")?.description ?? "";
30
31scenario(
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);
e2e/src/surfaces/mcp.ts+9−4
@@ -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 — an271 // `browser` mode is selected per the ecosystem convention — an
272 // `?elicitation_mode=` query on the MCP endpoint — so a paused execution272 // `?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?.elicitationMode274 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