diff --git a/.changeset/core-tools-integrations-remove.md b/.changeset/core-tools-integrations-remove.md new file mode 100644 index 0000000000..d4932f8ccb --- /dev/null +++ b/.changeset/core-tools-integrations-remove.md @@ -0,0 +1,9 @@ +--- +"executor": patch +--- + +**Add `integrations.remove` to the core tools so an agent can drop a catalog integration** + +`integrations.list` advertises `canRemove` per integration, but nothing on the agent surface could act on it: removal existed only on the HTTP API and the web console, so an agent that could add an integration could never take one back out. Cleaning up a catalog meant clicking through the UI once per integration. + +The core-tools plugin now contributes `integrations.remove`, taking the `slug` reported by `integrations.list` and cascading to every connection under the integration and the tools those produced. It is approval-gated, being strictly more destructive than `connections.remove`. The `removed` flag is honest rather than always-true: `false` means no catalog row matched, so an already-absent slug and a built-in namespace like `executor` are distinguishable from a real removal, and an integration pinned with `canRemove: false` is refused with `IntegrationRemovalNotAllowedError` instead of silently surviving. diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 2b0ef306e9..b01952747f 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -51,6 +51,8 @@ const IntegrationsListOutput = Schema.Struct({ integrations: Schema.Array(IntegrationOutput), }); +const IntegrationRemoveInput = Schema.Struct({ slug: Schema.String }); + const DetectInput = Schema.Struct({ url: Schema.String }); const DetectOutput = Schema.Struct({ results: Schema.Array( @@ -331,6 +333,7 @@ const OAuthCancelInput = Schema.Struct({ // Standard-schema versions for the tool() builder. const IntegrationsListOutputStd = schemaToStandard(IntegrationsListOutput); +const IntegrationRemoveInputStd = schemaToStandard(IntegrationRemoveInput); const DetectInputStd = schemaToStandard(DetectInput); const DetectOutputStd = schemaToStandard(DetectOutput); const ConnectionsListInputStd = schemaToStandard(ConnectionsListInput); @@ -559,6 +562,30 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { })), })), }), + tool({ + name: "integrations.remove", + description: + "Remove an integration from the workspace catalog by slug, dropping every connection under it and every tool those produced. `removed: false` means no catalog row matched: the slug was already gone, or it names a built-in namespace that is not catalog-backed. Integrations whose `canRemove` is false are refused.", + inputSchema: IntegrationRemoveInputStd, + outputSchema: RemovedOutputStd, + // Strictly more destructive than `connections.remove`, which is + // already approval-gated: this cascades to every connection under the + // integration and takes the catalog row with it, so re-adding means + // re-importing the definition, not just reconnecting an account. + annotations: { requiresApproval: true }, + execute: (input: typeof IntegrationRemoveInput.Type, { ctx }) => + Effect.gen(function* () { + const slug = IntegrationSlug.make(input.slug); + // `core.integrations.get` reads catalog ROWS only, so a built-in + // static namespace reports absent here. Checking first is what + // keeps `removed` honest — the underlying remove is a silent + // no-op for a slug it can't find. + const existing = yield* ctx.core.integrations.get(slug); + if (existing === null) return { removed: false }; + yield* ctx.core.integrations.remove(slug); + return { removed: true }; + }), + }), tool({ name: "connections.list", description: diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index f5d18cfc87..029b06d75d 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -38,6 +38,7 @@ const memoryProvider = (): CredentialProvider => { }; const INTEG = IntegrationSlug.make("demo"); +const PINNED = IntegrationSlug.make("demo-pinned"); const TEMPLATE = AuthTemplateSlug.make("apiKey"); const CONN = ConnectionName.make("main"); @@ -94,6 +95,15 @@ const demoPlugin = definePlugin(() => ({ description: "Demo", config: {}, }), + /** A catalog row the host pins in place (`canRemove: false`), the shape + * `integrations.remove` has to refuse rather than drop. */ + seedPinned: () => + ctx.core.integrations.register({ + slug: PINNED, + description: "Demo (pinned)", + config: {}, + canRemove: false, + }), storagePut: (owner: "org" | "user", key: string, value: string) => ctx.storage.put(owner, key, value), storageList: () => ctx.storage.list(), @@ -347,6 +357,66 @@ describe("createExecutor", () => { }), ); + it.effect("removes catalog integrations through the built-in Executor tools", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [demoPlugin] as const, + coreTools: {}, + }); + yield* executor.demo.seed(); + yield* executor.demo.seedPinned(); + yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.create"), + { + owner: "org", + name: String(CONN), + integration: String(INTEG), + template: String(TEMPLATE), + from: { provider: "memory", id: "secret-token" }, + }, + { onElicitation: "accept-all" }, + ); + + const remove = ToolAddress.make("executor.coreTools.integrations.remove"); + + // Removing the integration cascades to the connections under it. + const removed = yield* executor.execute( + remove, + { slug: String(INTEG) }, + { onElicitation: "accept-all" }, + ); + expect(removed).toEqual({ removed: true }); + const listed = yield* executor.integrations.list(); + expect(listed.map((integration) => String(integration.slug))).not.toContain(String(INTEG)); + expect(yield* executor.connections.list()).toHaveLength(0); + + // An already-absent slug and a built-in namespace both report honestly + // instead of claiming a removal that never happened. + expect( + yield* executor.execute(remove, { slug: String(INTEG) }, { onElicitation: "accept-all" }), + ).toEqual({ removed: false }); + expect( + yield* executor.execute(remove, { slug: "executor" }, { onElicitation: "accept-all" }), + ).toEqual({ removed: false }); + + // A pinned integration is refused, and survives the attempt. + const refused = yield* Effect.result( + executor.execute(remove, { slug: String(PINNED) }, { onElicitation: "accept-all" }), + ); + expect(Result.isFailure(refused)).toBe(true); + if (!Result.isFailure(refused)) return; + expect(Predicate.isTagged(refused.failure, "ToolInvocationError")).toBe(true); + expect( + Predicate.isTagged( + (refused.failure as { readonly cause?: unknown }).cause, + "IntegrationRemovalNotAllowedError", + ), + ).toBe(true); + const afterRefusal = yield* executor.integrations.list(); + expect(afterRefusal.map((integration) => String(integration.slug))).toContain(String(PINNED)); + }), + ); + it.effect("surfaces failed tool sync diagnostics through connection tools", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({