Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/desktop-toolkits-personal-connections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-toolkits": patch
---

Create personal toolkits by default on desktop and local hosts so new toolkits can use personal connections. Existing workspace toolkits retain their ownership restrictions.
5 changes: 5 additions & 0 deletions .changeset/secure-toolkit-workspace-policies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/sdk": patch
---

Enforce workspace approval and block policies when tools run through a toolkit-scoped executor.
11 changes: 10 additions & 1 deletion apps/cloud/src/mcp/session-build-semaphore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach } from "@effect/vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest";

import {
acquireBuildSlot,
Expand All @@ -13,6 +13,10 @@ describe("session-build-semaphore", () => {
resetBuildSlotsForTest();
});

afterEach(() => {
vi.useRealTimers();
});

it("grants up to the cap immediately, with no wait", async () => {
const results = await Promise.all([
acquireBuildSlot().promise,
Expand Down Expand Up @@ -214,6 +218,7 @@ describe("session-build-semaphore", () => {
});

it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => {
vi.useFakeTimers();
await Promise.all([
acquireBuildSlot().promise,
acquireBuildSlot().promise,
Expand All @@ -223,6 +228,10 @@ describe("session-build-semaphore", () => {
expect(currentActiveBuildsForTest()).toBe(4);

const timedOutHandle = acquireBuildSlot(10);
await vi.advanceTimersByTimeAsync(9);
expect(currentQueueLengthForTest()).toBe(1);
expect(currentActiveBuildsForTest()).toBe(4);
await vi.advanceTimersByTimeAsync(1);
const result = await timedOutHandle.promise;

expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true });
Expand Down
93 changes: 92 additions & 1 deletion e2e/local/toolkits-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";

import { scenario } from "../src/scenario";
import { Cli, RunDir } from "../src/services";
import { Browser, Cli, RunDir, Target } from "../src/services";
import { withLocalServer } from "./local-server";

const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const);
Expand Down Expand Up @@ -318,3 +318,94 @@ scenario(
}),
),
);

scenario(
"Local toolkits · UI creates a personal toolkit that executes a personal connection",
{ timeout: 240_000 },
Effect.scoped(
Effect.gen(function* () {
const cli = yield* Cli;
const runDir = yield* RunDir;
const browser = yield* Browser;
const target = yield* Target;
const identity = yield* target.newIdentity();
const upstream = yield* servePingApi;
yield* withLocalServer(cli, runDir, (server) =>
Effect.gen(function* () {
const client = yield* HttpApiClient.make(api, {
baseUrl: new URL("/api", server.origin).toString(),
transformClient: HttpClient.mapRequest((request) =>
HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
),
}).pipe(Effect.provide(FetchHttpClient.layer));
const integration = unique("personal_ping");
const name = unique("personal-kit");
yield* client.openapi.addSpec({
payload: {
spec: { kind: "blob", value: pingSpec(upstream.url) },
slug: IntegrationSlug.make(integration),
baseUrl: upstream.url,
authenticationTemplate: [
{
slug: "apiKey",
type: "apiKey",
headers: {
"x-e2e-token": [{ type: "variable", name: "token" }],
},
},
],
},
});
yield* client.connections.create({
payload: {
owner: "user",
name: ConnectionName.make("personal"),
integration: IntegrationSlug.make(integration),
template: AuthTemplateSlug.make("apiKey"),
value: "unused-token",
},
});
yield* browser.session(identity, async ({ page, step }) => {
await step("Create a toolkit on the local console", async () => {
await page.goto(server.url, { waitUntil: "domcontentloaded" });
await page.goto(new URL("/toolkits/", server.origin).toString());
await page.getByRole("button", { name: "Add toolkit", exact: true }).click();
await page.getByLabel("Toolkit name", { exact: true }).fill(name);
await page.getByRole("button", { name: "Create toolkit", exact: true }).click();
await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor();
});
});
const toolkit = (yield* client.toolkits.list()).toolkits.find((t) => t.name === name);
expect(toolkit?.owner).toBe("user");
if (!toolkit) return yield* Effect.die("Created toolkit missing");
yield* client.toolkits.createConnection({
params: { toolkitId: toolkit.id },
payload: { pattern: `${integration}.user.personal.*` },
});
const scoped = yield* Effect.acquireRelease(
Effect.promise(() =>
makeMcp(
new URL(`/mcp/toolkits/${toolkit.slug}`, server.origin).toString(),
server.token,
"personal-kit-test",
),
),
({ client }) => Effect.promise(() => client.close()).pipe(Effect.ignore),
);
const result = yield* Effect.promise(() =>
executeJson(
scoped.client,
callPingCode({
integration,
connection: "personal",
id: "personal-result",
}).replaceAll(`${integration}.org.`, `${integration}.user.`),
),
);
expect(result.ok).toBe(true);
expect(result.data).toMatchObject({ id: "personal-result" });
}),
);
}),
),
);
70 changes: 70 additions & 0 deletions e2e/scenarios/toolkits-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,3 +769,73 @@ scenario(
}),
),
);

scenario(
"Toolkits · workspace approval and block survive a toolkit approve rule",
{ timeout: 240_000 },
Effect.gen(function* () {
const target = yield* Target;
const mcp = yield* Mcp;
const apiSurface = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* apiSurface.client(api, identity);
const name = unique("workspace-policy-kit");
const createdPattern = `${unique("workspace-gated-result")}.*`;
const toolPattern = "executor.coreTools.policies.create";
yield* Effect.gen(function* () {
const toolkit = yield* client.toolkits.create({ payload: { owner: "org", name } });
yield* client.toolkits.createConnection({
params: { toolkitId: toolkit.id },
payload: { pattern: toolPattern },
});
yield* client.toolkits.createPolicy({
params: { toolkitId: toolkit.id },
payload: { pattern: toolPattern, action: "approve" },
});
yield* client.policies.create({
payload: { owner: "org", pattern: toolPattern, action: "require_approval" },
});
const session = mcp.session(identity, { url: toolkitUrl(target.baseUrl, toolkit.slug) });
const paused = yield* session.call("execute", {
code: createPolicyCode({ pattern: createdPattern, action: "block" }),
});
expect(paused.text).toContain("Execution paused");
expect((yield* client.policies.list()).some((p) => p.pattern === createdPattern)).toBe(false);
const resumed = yield* session.approvePaused(paused.text);
expect(resumed.ok).toBe(true);
expect((yield* client.policies.list()).some((p) => p.pattern === createdPattern)).toBe(true);
yield* client.policies.create({
payload: { owner: "org", pattern: toolPattern, action: "block" },
});
const blockedPattern = `${createdPattern}blocked`;
const blockedSession = mcp.session(identity, {
url: toolkitUrl(target.baseUrl, toolkit.slug),
});
const blocked = yield* blockedSession.call("execute", {
code: createPolicyCode({ pattern: blockedPattern, action: "block" }),
});
expect(blocked.text).not.toContain("Execution paused");
expect((yield* client.policies.list()).some((p) => p.pattern === blockedPattern)).toBe(false);
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
const listed = yield* client.toolkits.list();
yield* Effect.forEach(
listed.toolkits.filter((t) => t.name === name),
(t) => client.toolkits.remove({ params: { toolkitId: t.id } }),
{ discard: true },
);
const policies = yield* client.policies.list();
yield* Effect.forEach(
policies.filter(
(p) => p.pattern === toolPattern || p.pattern.startsWith(createdPattern),
),
(p) =>
client.policies.remove({ params: { policyId: p.id }, payload: { owner: p.owner } }),
{ discard: true },
);
}).pipe(Effect.ignore),
),
);
}),
);
99 changes: 56 additions & 43 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ import {
} from "./oauth-client";
import type { FirstPartyOAuthClientConfig } from "./oauth-client";
import {
combineEffectivePolicies,
comparePolicyRow,
isValidPattern,
matchPattern,
Expand Down Expand Up @@ -5290,13 +5291,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
readonly kind: "provider";
readonly provider: ToolPolicyProvider;
readonly rules: readonly ToolPolicyProviderRule[] | null;
readonly workspaceRows: readonly ToolPolicyRow[];
}
| {
readonly kind: "prepared";
readonly resolve: (input: {
readonly toolId: string;
readonly defaultRequiresApproval?: boolean;
}) => EffectivePolicy;
readonly workspaceRows: readonly ToolPolicyRow[];
};

const compareProviderPolicyRule = (
Expand Down Expand Up @@ -5331,53 +5334,63 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
};

const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> =>
activeToolPolicyProvider
? // Batched per-operation resolver: fetch all policy + connection state
// once, then resolve every tool in this operation against that
// snapshot. Avoids the per-tool resolve N+1 on the list surface.
activeToolPolicyProvider.prepare
? activeToolPolicyProvider.prepare().pipe(
Effect.map((resolve) => ({
kind: "prepared" as const,
resolve,
})),
)
: activeToolPolicyProvider.resolve
? Effect.succeed({
kind: "provider" as const,
provider: activeToolPolicyProvider,
rules: null,
})
: activeToolPolicyProvider.list().pipe(
Effect.map((rules) => ({
kind: "provider" as const,
provider: activeToolPolicyProvider!,
rules,
})),
)
: core
.findMany("tool_policy", {})
.pipe(Effect.map((rows) => ({ kind: "global" as const, rows })));
Effect.gen(function* () {
// Fetch workspace policy once per operation, then reuse the provider's
// prepared snapshot for every tool on the list surface.
const workspaceRows = yield* core.findMany("tool_policy", {});
if (!activeToolPolicyProvider) {
return { kind: "global" as const, rows: workspaceRows };
}
if (activeToolPolicyProvider.prepare) {
const resolve = yield* activeToolPolicyProvider.prepare();
return { kind: "prepared" as const, resolve, workspaceRows };
}
if (activeToolPolicyProvider.resolve) {
return {
kind: "provider" as const,
provider: activeToolPolicyProvider,
rules: null,
workspaceRows,
};
}
const rules = yield* activeToolPolicyProvider.list();
return {
kind: "provider" as const,
provider: activeToolPolicyProvider,
rules,
workspaceRows,
};
});

const resolvePolicyFromRuleSet = (
toolId: string,
ruleSet: ActivePolicyRuleSet,
defaultRequiresApproval?: boolean,
): Effect.Effect<EffectivePolicy, StorageFailure> =>
ruleSet.kind === "prepared"
? Effect.succeed(ruleSet.resolve({ toolId, defaultRequiresApproval }))
: ruleSet.kind === "provider"
? ruleSet.provider.resolve
? ruleSet.provider.resolve({ toolId, defaultRequiresApproval })
: Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? []))
: Effect.succeed(
resolveEffectivePolicy(
toolId,
ruleSet.rows,
ownerRankForRow,
defaultRequiresApproval,
),
);
Effect.gen(function* () {
if (ruleSet.kind === "global") {
return resolveEffectivePolicy(
toolId,
ruleSet.rows,
ownerRankForRow,
defaultRequiresApproval,
);
}

const workspacePolicy = resolveEffectivePolicy(
toolId,
ruleSet.workspaceRows,
ownerRankForRow,
defaultRequiresApproval,
);
const providerPolicy =
ruleSet.kind === "prepared"
? ruleSet.resolve({ toolId, defaultRequiresApproval })
: ruleSet.provider.resolve
? yield* ruleSet.provider.resolve({ toolId, defaultRequiresApproval })
: resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? []);
return combineEffectivePolicies(providerPolicy, workspacePolicy);
});

// ------------------------------------------------------------------
// Tools (read surface)
Expand Down Expand Up @@ -5954,7 +5967,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
): Effect.Effect<EffectivePolicy, StorageFailure> =>
Effect.gen(function* () {
const parsed = parseToolAddress(String(address));
const policyRows = yield* core.findMany("tool_policy", {});
const toolId = parsed
? `${parsed.integration}.${parsed.owner}.${parsed.connection}.${parsed.tool}`
: String(address);
Expand All @@ -5975,7 +5987,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
requiresApproval = annotations?.requiresApproval;
}
}
return resolveEffectivePolicy(toolId, policyRows, ownerRankForRow, requiresApproval);
const ruleSet = yield* listActivePolicyRuleSet();
return yield* resolvePolicyFromRuleSet(toolId, ruleSet, requiresApproval);
});

// ------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export {
matchPattern,
isValidPattern,
effectivePolicyFromSorted,
combineEffectivePolicies,
ToolPolicyActionSchema,
type ToolPolicy,
type CreateToolPolicyInput,
Expand Down
Loading
Loading