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