From 97f38a6f3730a0da549d734b9e0cc5b43690c460 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 22:24:52 -0700 Subject: [PATCH 1/2] feat(sim-cli): publish which CLI commands Mothership is refused, from the route admission rule --- .../server/routes/copilot-refused-routes.json | 292 +++++++++++++ .../lib/api/server/routes/copilot-request.ts | 28 +- .../routes/copilot-route-inventory.test.ts | 390 ++---------------- .../lib/api/server/routes/v2-json-route.ts | 11 +- .../scripts/print-command-inventory.ts | 33 +- packages/sim-cli/src/commands/auth.ts | 4 +- .../src/commands/protocol/files-get.ts | 4 +- .../sim-cli/src/contract/reference.test.ts | 19 +- .../sim-cli/src/runtime/called-operations.ts | 20 + 9 files changed, 424 insertions(+), 377 deletions(-) create mode 100644 apps/sim/lib/api/server/routes/copilot-refused-routes.json create mode 100644 packages/sim-cli/src/runtime/called-operations.ts diff --git a/apps/sim/lib/api/server/routes/copilot-refused-routes.json b/apps/sim/lib/api/server/routes/copilot-refused-routes.json new file mode 100644 index 00000000000..3b5dfa1a9f9 --- /dev/null +++ b/apps/sim/lib/api/server/routes/copilot-refused-routes.json @@ -0,0 +1,292 @@ +[ + { + "method": "GET", + "path": "/api/v2/credentials/[credentialId]/members", + "operation": "credentials.members.list" + }, + { + "method": "POST", + "path": "/api/v2/credentials/[credentialId]/members", + "operation": "credentials.members.upsert" + }, + { + "method": "DELETE", + "path": "/api/v2/credentials/[credentialId]/members/[userId]", + "operation": "credentials.members.remove" + }, + { + "method": "GET", + "path": "/api/v2/files/[fileId]/versions", + "operation": "files.versions.list" + }, + { + "method": "GET", + "path": "/api/v2/files/[fileId]/versions/[version]", + "operation": "files.versions.read" + }, + { + "method": "DELETE", + "path": "/api/v2/files/[fileId]/versions/[version]", + "operation": "files.versions.delete" + }, + { + "method": "GET", + "path": "/api/v2/files/[fileId]/versions/[version]/content", + "operation": "files.versions.download" + }, + { + "method": "POST", + "path": "/api/v2/files/[fileId]/versions/[version]/revert", + "operation": "files.versions.revert" + }, + { + "method": "GET", + "path": "/api/v2/files/[fileId]/versions/[version]/text", + "operation": "files.versions.read_content" + }, + { + "method": "GET", + "path": "/api/v2/meta", + "operation": "meta.capabilities.read" + }, + { + "method": "GET", + "path": "/api/v2/organizations", + "operation": "organizations.list" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]", + "operation": "organizations.read" + }, + { + "method": "POST", + "path": "/api/v2/organizations/[organizationId]/access-requests", + "operation": "access_requests.create" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/access-requests", + "operation": "access_requests.list_organization" + }, + { + "method": "POST", + "path": "/api/v2/organizations/[organizationId]/access-requests/[requestId]/cancel", + "operation": "access_requests.cancel" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/access-requests/[requestId]/preview", + "operation": "access_requests.preview" + }, + { + "method": "POST", + "path": "/api/v2/organizations/[organizationId]/access-requests/[requestId]/resolve", + "operation": "access_requests.resolve" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/access-requests/discovery", + "operation": "access_requests.discover" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/access-requests/mine", + "operation": "access_requests.list_mine" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/access-requests/settings", + "operation": "access_requests.get_settings" + }, + { + "method": "PATCH", + "path": "/api/v2/organizations/[organizationId]/access-requests/settings", + "operation": "access_requests.update_settings" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/invitations", + "operation": "organizations.invitations.list" + }, + { + "method": "POST", + "path": "/api/v2/organizations/[organizationId]/invitations", + "operation": "organizations.invitations.create" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/invitations/[invitationId]", + "operation": "organizations.invitations.read" + }, + { + "method": "DELETE", + "path": "/api/v2/organizations/[organizationId]/invitations/[invitationId]", + "operation": "invitations.revoke" + }, + { + "method": "POST", + "path": "/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend", + "operation": "invitations.resend" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces", + "operation": "organizations.invitations.workspaces.list" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/members", + "operation": "organizations.members.list" + }, + { + "method": "PATCH", + "path": "/api/v2/organizations/[organizationId]/members/[userId]", + "operation": "organizations.members.update" + }, + { + "method": "DELETE", + "path": "/api/v2/organizations/[organizationId]/members/[userId]", + "operation": "organizations.members.remove" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/members/[userId]/usage-limit", + "operation": "organization_member_usage_limits.read" + }, + { + "method": "PATCH", + "path": "/api/v2/organizations/[organizationId]/members/[userId]/usage-limit", + "operation": "organization_member_usage_limits.update" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/permission-groups", + "operation": "permission_groups.list" + }, + { + "method": "POST", + "path": "/api/v2/organizations/[organizationId]/permission-groups", + "operation": "permission_groups.create" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]", + "operation": "permission_groups.read" + }, + { + "method": "PATCH", + "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]", + "operation": "permission_groups.update" + }, + { + "method": "DELETE", + "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]", + "operation": "permission_groups.delete" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members", + "operation": "permission_groups.members.list" + }, + { + "method": "POST", + "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members", + "operation": "permission_groups.members.add" + }, + { + "method": "DELETE", + "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]", + "operation": "permission_groups.members.remove" + }, + { + "method": "POST", + "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk", + "operation": "permission_groups.members.bulk_add" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/sso/policy", + "operation": "organization.sso.read_requirement" + }, + { + "method": "PATCH", + "path": "/api/v2/organizations/[organizationId]/sso/policy", + "operation": "organization.sso.set_requirement" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/sso/providers", + "operation": "organization.sso.providers.list" + }, + { + "method": "POST", + "path": "/api/v2/organizations/[organizationId]/sso/providers", + "operation": "organization.sso.providers.save" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/sso/providers/[providerId]", + "operation": "organization.sso.providers.list" + }, + { + "method": "DELETE", + "path": "/api/v2/organizations/[organizationId]/sso/providers/[providerId]", + "operation": "organization.sso.providers.delete" + }, + { + "method": "POST", + "path": "/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary", + "operation": "organization.sso.set_primary_provider" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/usage/breakdown", + "operation": "organization_usage.breakdown.read" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/usage/events", + "operation": "organization_usage.events.list" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/usage/summary", + "operation": "organization_usage.summary.read" + }, + { + "method": "GET", + "path": "/api/v2/organizations/[organizationId]/workspaces", + "operation": "organizations.workspaces.list" + }, + { + "method": "GET", + "path": "/api/v2/workspaces", + "operation": "workspaces.list_public" + }, + { + "method": "GET", + "path": "/api/v2/workspaces/[workspaceId]/access-requests", + "operation": "access_requests.list_mine" + }, + { + "method": "POST", + "path": "/api/v2/workspaces/[workspaceId]/access-requests", + "operation": "access_requests.create" + }, + { + "method": "POST", + "path": "/api/v2/workspaces/[workspaceId]/access-requests/[requestId]/cancel", + "operation": "access_requests.cancel" + }, + { + "method": "GET", + "path": "/api/v2/workspaces/[workspaceId]/access-requests/discovery", + "operation": "access_requests.discover" + }, + { + "method": "GET", + "path": "/api/v2/workspaces/[workspaceId]/permission-config", + "operation": "permission_groups.read_user_config" + } +] diff --git a/apps/sim/lib/api/server/routes/copilot-request.ts b/apps/sim/lib/api/server/routes/copilot-request.ts index 5408444aa48..15e841b3e2c 100644 --- a/apps/sim/lib/api/server/routes/copilot-request.ts +++ b/apps/sim/lib/api/server/routes/copilot-request.ts @@ -20,19 +20,33 @@ export function isCopilotRequest(request: Request): boolean { return INVOCATIONS.has(request) } -/** The code-owned use case must explicitly admit Copilot and declare its domain audience. */ +export type CopilotRouteUseCase = Pick< + OperationUseCase, + 'operation' | 'delegationAudience' +> + +/** + * The audience a route admits Copilot under, or none when Mothership is refused. The + * code-owned use case must explicitly admit Copilot and declare its domain audience. The + * route inventory evaluates this same rule to publish which CLI commands chat can run. + */ +export function copilotRouteAudience( + operation: ApplicationOperation, + useCase?: CopilotRouteUseCase +): string | undefined { + return useCase?.operation === operation ? useCase.delegationAudience || undefined : undefined +} + export function copilotRequestPrincipal( request: Request, operation: ApplicationOperation, - useCase?: Pick< - OperationUseCase, - 'operation' | 'delegationAudience' - > + useCase?: CopilotRouteUseCase ): DelegatedPrincipal | undefined { const invocation = INVOCATIONS.get(request) if (!invocation) return undefined - if (useCase?.operation !== operation || !useCase.delegationAudience) return undefined - const principal = createCopilotChatPrincipal(invocation, useCase.delegationAudience) + const audience = copilotRouteAudience(operation, useCase) + if (!audience) return undefined + const principal = createCopilotChatPrincipal(invocation, audience) markCopilotWorkspaceInvocation(principal) return principal } diff --git a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts index 1818f668fa4..f7eede46bbd 100644 --- a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts +++ b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts @@ -7,20 +7,26 @@ vi.mock('@/triggers', () => triggersMock) vi.mock('@/lib/mothership/request/lifecycle/headless', () => mothershipHeadlessLifecycleMock) const inventory = vi.hoisted( - () => [] as Array<{ method: string; path: string; operation: string; audience: string | null }> + () => + [] as Array<{ + method: string + path: string + operation: ApplicationOperation + useCase: CopilotRouteUseCase + }> ) const builder = vi.hoisted( () => (options: { contract: { method: string; path: string } - operation: { id: string } - useCase: { delegationAudience?: string } + operation: ApplicationOperation + useCase: CopilotRouteUseCase }) => { inventory.push({ method: options.contract.method, path: options.contract.path, - operation: options.operation.id, - audience: options.useCase.delegationAudience ?? null, + operation: options.operation, + useCase: options.useCase, }) return async () => new Response() } @@ -38,365 +44,29 @@ vi.mock('@/lib/api/server/routes/v2-body-lifecycle-route', () => ({ defineV2BodyLifecycleRoute: builder, })) +import { + type CopilotRouteUseCase, + copilotRouteAudience, +} from '@/lib/api/server/routes/copilot-request' import { V2_ROUTES } from '@/lib/api/server/routes/v2-route-table.generated' +import type { ApplicationOperation } from '@/lib/core/application/operation' + +/** + * Routes Mothership refuses, judged by the rule admission itself runs. The CLI command + * inventory (`packages/sim-cli/scripts/print-command-inventory.ts`) reads this file to mark + * the commands chat cannot run, so the agent is never shown one. After changing a use + * case's delegation, refresh it with `vitest run -u` on this file and review the diff. + */ +const REFUSED_ROUTES_FILE = './copilot-refused-routes.json' it('inventories private operation admission without executing route requests', async () => { for (const route of V2_ROUTES) await route.load() expect(inventory.length).toBeGreaterThan(200) + const audiences = inventory.map((route) => copilotRouteAudience(route.operation, route.useCase)) /** Public organization and version-history operations require a direct caller. */ - expect(inventory.filter((route) => !route.audience)).toMatchInlineSnapshot(` - [ - { - "audience": null, - "method": "GET", - "operation": "credentials.members.list", - "path": "/api/v2/credentials/[credentialId]/members", - }, - { - "audience": null, - "method": "POST", - "operation": "credentials.members.upsert", - "path": "/api/v2/credentials/[credentialId]/members", - }, - { - "audience": null, - "method": "DELETE", - "operation": "credentials.members.remove", - "path": "/api/v2/credentials/[credentialId]/members/[userId]", - }, - { - "audience": null, - "method": "GET", - "operation": "files.versions.list", - "path": "/api/v2/files/[fileId]/versions", - }, - { - "audience": null, - "method": "GET", - "operation": "files.versions.read", - "path": "/api/v2/files/[fileId]/versions/[version]", - }, - { - "audience": null, - "method": "DELETE", - "operation": "files.versions.delete", - "path": "/api/v2/files/[fileId]/versions/[version]", - }, - { - "audience": null, - "method": "GET", - "operation": "files.versions.download", - "path": "/api/v2/files/[fileId]/versions/[version]/content", - }, - { - "audience": null, - "method": "POST", - "operation": "files.versions.revert", - "path": "/api/v2/files/[fileId]/versions/[version]/revert", - }, - { - "audience": null, - "method": "GET", - "operation": "files.versions.read_content", - "path": "/api/v2/files/[fileId]/versions/[version]/text", - }, - { - "audience": null, - "method": "GET", - "operation": "meta.capabilities.read", - "path": "/api/v2/meta", - }, - { - "audience": null, - "method": "GET", - "operation": "organizations.list", - "path": "/api/v2/organizations", - }, - { - "audience": null, - "method": "GET", - "operation": "organizations.read", - "path": "/api/v2/organizations/[organizationId]", - }, - { - "audience": null, - "method": "POST", - "operation": "access_requests.create", - "path": "/api/v2/organizations/[organizationId]/access-requests", - }, - { - "audience": null, - "method": "GET", - "operation": "access_requests.list_organization", - "path": "/api/v2/organizations/[organizationId]/access-requests", - }, - { - "audience": null, - "method": "POST", - "operation": "access_requests.cancel", - "path": "/api/v2/organizations/[organizationId]/access-requests/[requestId]/cancel", - }, - { - "audience": null, - "method": "GET", - "operation": "access_requests.preview", - "path": "/api/v2/organizations/[organizationId]/access-requests/[requestId]/preview", - }, - { - "audience": null, - "method": "POST", - "operation": "access_requests.resolve", - "path": "/api/v2/organizations/[organizationId]/access-requests/[requestId]/resolve", - }, - { - "audience": null, - "method": "GET", - "operation": "access_requests.discover", - "path": "/api/v2/organizations/[organizationId]/access-requests/discovery", - }, - { - "audience": null, - "method": "GET", - "operation": "access_requests.list_mine", - "path": "/api/v2/organizations/[organizationId]/access-requests/mine", - }, - { - "audience": null, - "method": "GET", - "operation": "access_requests.get_settings", - "path": "/api/v2/organizations/[organizationId]/access-requests/settings", - }, - { - "audience": null, - "method": "PATCH", - "operation": "access_requests.update_settings", - "path": "/api/v2/organizations/[organizationId]/access-requests/settings", - }, - { - "audience": null, - "method": "GET", - "operation": "organizations.invitations.list", - "path": "/api/v2/organizations/[organizationId]/invitations", - }, - { - "audience": null, - "method": "POST", - "operation": "organizations.invitations.create", - "path": "/api/v2/organizations/[organizationId]/invitations", - }, - { - "audience": null, - "method": "GET", - "operation": "organizations.invitations.read", - "path": "/api/v2/organizations/[organizationId]/invitations/[invitationId]", - }, - { - "audience": null, - "method": "DELETE", - "operation": "invitations.revoke", - "path": "/api/v2/organizations/[organizationId]/invitations/[invitationId]", - }, - { - "audience": null, - "method": "POST", - "operation": "invitations.resend", - "path": "/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend", - }, - { - "audience": null, - "method": "GET", - "operation": "organizations.invitations.workspaces.list", - "path": "/api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces", - }, - { - "audience": null, - "method": "GET", - "operation": "organizations.members.list", - "path": "/api/v2/organizations/[organizationId]/members", - }, - { - "audience": null, - "method": "PATCH", - "operation": "organizations.members.update", - "path": "/api/v2/organizations/[organizationId]/members/[userId]", - }, - { - "audience": null, - "method": "DELETE", - "operation": "organizations.members.remove", - "path": "/api/v2/organizations/[organizationId]/members/[userId]", - }, - { - "audience": null, - "method": "GET", - "operation": "organization_member_usage_limits.read", - "path": "/api/v2/organizations/[organizationId]/members/[userId]/usage-limit", - }, - { - "audience": null, - "method": "PATCH", - "operation": "organization_member_usage_limits.update", - "path": "/api/v2/organizations/[organizationId]/members/[userId]/usage-limit", - }, - { - "audience": null, - "method": "GET", - "operation": "permission_groups.list", - "path": "/api/v2/organizations/[organizationId]/permission-groups", - }, - { - "audience": null, - "method": "POST", - "operation": "permission_groups.create", - "path": "/api/v2/organizations/[organizationId]/permission-groups", - }, - { - "audience": null, - "method": "GET", - "operation": "permission_groups.read", - "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]", - }, - { - "audience": null, - "method": "PATCH", - "operation": "permission_groups.update", - "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]", - }, - { - "audience": null, - "method": "DELETE", - "operation": "permission_groups.delete", - "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]", - }, - { - "audience": null, - "method": "GET", - "operation": "permission_groups.members.list", - "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members", - }, - { - "audience": null, - "method": "POST", - "operation": "permission_groups.members.add", - "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members", - }, - { - "audience": null, - "method": "DELETE", - "operation": "permission_groups.members.remove", - "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]", - }, - { - "audience": null, - "method": "POST", - "operation": "permission_groups.members.bulk_add", - "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk", - }, - { - "audience": null, - "method": "GET", - "operation": "organization.sso.read_requirement", - "path": "/api/v2/organizations/[organizationId]/sso/policy", - }, - { - "audience": null, - "method": "PATCH", - "operation": "organization.sso.set_requirement", - "path": "/api/v2/organizations/[organizationId]/sso/policy", - }, - { - "audience": null, - "method": "GET", - "operation": "organization.sso.providers.list", - "path": "/api/v2/organizations/[organizationId]/sso/providers", - }, - { - "audience": null, - "method": "POST", - "operation": "organization.sso.providers.save", - "path": "/api/v2/organizations/[organizationId]/sso/providers", - }, - { - "audience": null, - "method": "GET", - "operation": "organization.sso.providers.list", - "path": "/api/v2/organizations/[organizationId]/sso/providers/[providerId]", - }, - { - "audience": null, - "method": "DELETE", - "operation": "organization.sso.providers.delete", - "path": "/api/v2/organizations/[organizationId]/sso/providers/[providerId]", - }, - { - "audience": null, - "method": "POST", - "operation": "organization.sso.set_primary_provider", - "path": "/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary", - }, - { - "audience": null, - "method": "GET", - "operation": "organization_usage.breakdown.read", - "path": "/api/v2/organizations/[organizationId]/usage/breakdown", - }, - { - "audience": null, - "method": "GET", - "operation": "organization_usage.events.list", - "path": "/api/v2/organizations/[organizationId]/usage/events", - }, - { - "audience": null, - "method": "GET", - "operation": "organization_usage.summary.read", - "path": "/api/v2/organizations/[organizationId]/usage/summary", - }, - { - "audience": null, - "method": "GET", - "operation": "organizations.workspaces.list", - "path": "/api/v2/organizations/[organizationId]/workspaces", - }, - { - "audience": null, - "method": "GET", - "operation": "workspaces.list_public", - "path": "/api/v2/workspaces", - }, - { - "audience": null, - "method": "GET", - "operation": "access_requests.list_mine", - "path": "/api/v2/workspaces/[workspaceId]/access-requests", - }, - { - "audience": null, - "method": "POST", - "operation": "access_requests.create", - "path": "/api/v2/workspaces/[workspaceId]/access-requests", - }, - { - "audience": null, - "method": "POST", - "operation": "access_requests.cancel", - "path": "/api/v2/workspaces/[workspaceId]/access-requests/[requestId]/cancel", - }, - { - "audience": null, - "method": "GET", - "operation": "access_requests.discover", - "path": "/api/v2/workspaces/[workspaceId]/access-requests/discovery", - }, - { - "audience": null, - "method": "GET", - "operation": "permission_groups.read_user_config", - "path": "/api/v2/workspaces/[workspaceId]/permission-config", - }, - ] - `) - expect( - inventory.filter((route) => route.audience).every((route) => route.audience?.startsWith('sim:')) - ).toBe(true) + const refused = inventory + .filter((_, index) => !audiences[index]) + .map(({ method, path, operation }) => ({ method, path, operation: operation.id })) + await expect(`${JSON.stringify(refused, null, 2)}\n`).toMatchFileSnapshot(REFUSED_ROUTES_FILE) + expect(audiences.filter(Boolean).every((audience) => audience?.startsWith('sim:'))).toBe(true) }, 60000) diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 153b52c81c4..69f219a6d37 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -4,7 +4,11 @@ import { setRequestAuth } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' -import { copilotRequestPrincipal, isCopilotRequest } from '@/lib/api/server/routes/copilot-request' +import { + type CopilotRouteUseCase, + copilotRequestPrincipal, + isCopilotRequest, +} from '@/lib/api/server/routes/copilot-request' import { methodMatchesContract, requireJsonRouteDefinition, @@ -369,11 +373,6 @@ type V2AdmissionAuth = keyExpiresAt?: undefined } -type CopilotRouteUseCase = Pick< - OperationUseCase, - 'operation' | 'delegationAudience' -> - async function admitRateLimitedV2Request( request: NextRequest, operation: ApplicationOperation, diff --git a/packages/sim-cli/scripts/print-command-inventory.ts b/packages/sim-cli/scripts/print-command-inventory.ts index 0d58e3eb108..37d263c1115 100644 --- a/packages/sim-cli/scripts/print-command-inventory.ts +++ b/packages/sim-cli/scripts/print-command-inventory.ts @@ -5,7 +5,8 @@ * The source is `buildProgram()` — the same command tree `--help` and the generated docs * read — so the model's card can never describe a command the CLI does not have. Each * leaf carries its positionals and options as commander declares them, plus the top-level - * shape of its JSON response resolved from the v2 OpenAPI documents. + * shape of its JSON response resolved from the v2 OpenAPI documents, and + * `mothershipUnavailable` when Sim refuses a Mothership caller any operation it calls. * * bun run packages/sim-cli/scripts/print-command-inventory.ts > inventory.json */ @@ -21,6 +22,7 @@ import { } from '#sim-cli/contract/reference' import { V2_OPERATIONS, type V2OperationName } from '#sim-cli/generated/v2-api' import { buildProgram } from '#sim-cli/program' +import { calledOperations } from '#sim-cli/runtime/called-operations' import { camel, deriveCommandPath } from '#sim-cli/runtime/derive' import { cursorSlot, flagNameFor } from '#sim-cli/runtime/request' @@ -51,6 +53,11 @@ interface InventoryCommand extends CommandReference { description: string args: InventoryArgument[] options: InventoryOption[] + /** + * Sim refuses Mothership for an operation this command calls, so chat cannot run it. + * Absent when every call is admitted or the command calls no known operation. + */ + mothershipUnavailable?: true } function isHiddenCommand(command: Command): boolean { @@ -89,6 +96,28 @@ const OPENAPI_DOCS: ReferenceDocument[] = fs .filter((name) => /^openapi-v2-.*\.json$/.test(name)) .map((name) => JSON.parse(fs.readFileSync(path.join(ROOT, 'apps/docs', name), 'utf8'))) +/** + * `METHOD path` of every route Sim refuses a Mothership caller, as the route inventory + * evaluates the admission rule (`apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts`). + */ +const MOTHERSHIP_REFUSED = new Set( + ( + JSON.parse( + fs.readFileSync( + path.join(ROOT, 'apps/sim/lib/api/server/routes/copilot-refused-routes.json'), + 'utf8' + ) + ) as { method: string; path: string }[] + ).map((route) => `${route.method} ${route.path}`) +) + +function mothershipRefuses(operations: readonly V2OperationName[]): boolean { + return operations.some((name) => { + const { method, path: route } = V2_OPERATIONS[name] + return MOTHERSHIP_REFUSED.has(`${method} ${route}`) + }) +} + const program = buildProgram() const inventory: InventoryCommand[] = collectLeaves(program, []).map( ({ path: cmdPath, command }) => { @@ -128,12 +157,14 @@ const inventory: InventoryCommand[] = collectLeaves(program, []).map( const reference = op ? commandReference(OPENAPI_DOCS, op, jsonFields, cursorSlot(op) !== null) : {} + const called = calledOperations(command) ?? (operation ? [operation] : []) return { path: cmdPath, description: command.description(), args, options, ...reference, + ...(mothershipRefuses(called) ? { mothershipUnavailable: true as const } : {}), } } ) diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 91b8fc46261..295f93110eb 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -55,6 +55,7 @@ import { } from '../generated/v2-api' import { requestAllPages, resolvePath, SimApiError, type SimClient } from '../http/client' import { type Column, printList, printRecord, safeOneLine, text } from '../output/render' +import { callsOperations } from '../runtime/called-operations' type SelectableWorkspace = ListWorkspacesResponse['data'][number] @@ -967,7 +968,7 @@ function presentVerification(verification: Verification): string { } export function whoamiCommand(): Command { - return new Command('whoami') + const whoami = new Command('whoami') .description('Show the resolved profile, where each setting came from, and whether it works') .option('--no-verify', 'Skip the API check and only print the resolved settings') .action(async (options: { verify: boolean }, command: Command) => { @@ -1034,6 +1035,7 @@ export function whoamiCommand(): Command { const exitCode = WHOAMI_EXIT_CODES[verification.status] if (exitCode !== 0) setSoftExitCode(exitCode) }) + return callsOperations(whoami, ['getMeta', 'getWorkspace']) } interface ProfileRow { diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index be36e1feaec..18b73131ae9 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -10,6 +10,7 @@ import { clientFrom } from '../../context' import { embedStore } from '../../embed-context' import { V2_OPERATIONS } from '../../generated/v2-api' import { isRequestTimeout, RAISE_TIMEOUT_HINT, resolvePath, SimApiError } from '../../http/client' +import { callsOperations } from '../../runtime/called-operations' import { printProtocolResult } from './result' function writeFailure(path: WriteStream['path'], error: unknown): SimApiError { @@ -349,7 +350,7 @@ export function attachFileGet(files: Command): void { } export function attachFileVersionDownload(versions: Command): void { - versions + const download = versions .command('download') .argument('', 'File identifier.') .argument('', 'Version number.') @@ -360,4 +361,5 @@ export function attachFileVersionDownload(versions: Command): void { .action((fileId: string, version: string, options: DownloadOutputOptions, command: Command) => downloadToOutput(command, V2_OPERATIONS.downloadFileVersion, { fileId, version }, options) ) + callsOperations(download, ['downloadFileVersion']) } diff --git a/packages/sim-cli/src/contract/reference.test.ts b/packages/sim-cli/src/contract/reference.test.ts index e43483ec12e..2c19bdcafb1 100644 --- a/packages/sim-cli/src/contract/reference.test.ts +++ b/packages/sim-cli/src/contract/reference.test.ts @@ -13,7 +13,12 @@ import { cursorSlot } from '#sim-cli/runtime/request' const ROOT = fileURLToPath(new URL('../../../../', import.meta.url)) /** Read the actual producer, including command aliases and generated API documents. */ -function inventory(): { path: string[]; shape?: string; body?: string }[] { +function inventory(): { + path: string[] + shape?: string + body?: string + mothershipUnavailable?: true +}[] { return JSON.parse( execFileSync('bun', ['run', 'packages/sim-cli/scripts/print-command-inventory.ts'], { cwd: ROOT, @@ -62,6 +67,18 @@ describe('CLI reference producer', () => { expect(find('files share get').shape).toContain('|{data:null}') }) + it('marks every command that calls a route Mothership is refused', () => { + const commands = inventory() + const flag = (path: string) => + commands.find((entry) => entry.path.join(' ') === path)?.mothershipUnavailable + expect(flag('meta status')).toBe(true) + expect(flag('whoami')).toBe(true) + expect(flag('files versions download')).toBe(true) + expect(flag('workspaces get')).toBeUndefined() + expect(flag('workspaces invitations create')).toBeUndefined() + expect(flag('files get')).toBeUndefined() + }) + it('the real CLI accepts both row bodies and prints their distinct 201 payloads', async () => { const identity = { endpoint: 'https://sim.internal.test', diff --git a/packages/sim-cli/src/runtime/called-operations.ts b/packages/sim-cli/src/runtime/called-operations.ts new file mode 100644 index 00000000000..2ad99643ff9 --- /dev/null +++ b/packages/sim-cli/src/runtime/called-operations.ts @@ -0,0 +1,20 @@ +import type { Command } from 'commander' +import type { V2OperationName } from '../generated/v2-api' + +const CALLED_OPERATIONS = new WeakMap() + +/** + * Records the v2 operations a hand-written command calls. + * + * A generated command is one operation and the command inventory finds it by path. A + * hand-written one is opaque until it says what it calls, and the inventory needs that + * to describe it truthfully, such as whether every call is open to a Mothership caller. + */ +export function callsOperations(command: Command, operations: readonly V2OperationName[]): Command { + CALLED_OPERATIONS.set(command, operations) + return command +} + +export function calledOperations(command: Command): readonly V2OperationName[] | undefined { + return CALLED_OPERATIONS.get(command) +} From f5561ef1f35a265739ff96b431336c576d65de4b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 22:35:31 -0700 Subject: [PATCH 2/2] fix(sim-cli): inventory raw-route admission so chat is flagged too --- .../server/routes/copilot-refused-routes.json | 5 ++ .../routes/copilot-route-inventory.test.ts | 46 +++++++++++++++++-- .../sim-cli/src/commands/protocol/chat.ts | 4 +- .../sim-cli/src/contract/reference.test.ts | 2 + 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/api/server/routes/copilot-refused-routes.json b/apps/sim/lib/api/server/routes/copilot-refused-routes.json index 3b5dfa1a9f9..60a9555df36 100644 --- a/apps/sim/lib/api/server/routes/copilot-refused-routes.json +++ b/apps/sim/lib/api/server/routes/copilot-refused-routes.json @@ -1,4 +1,9 @@ [ + { + "method": "POST", + "path": "/api/v2/chat", + "operation": "chat.send" + }, { "method": "GET", "path": "/api/v2/credentials/[credentialId]/members", diff --git a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts index f7eede46bbd..a8d47e99076 100644 --- a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts +++ b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts @@ -15,6 +15,7 @@ const inventory = vi.hoisted( useCase: CopilotRouteUseCase }> ) +const built = vi.hoisted(() => new WeakSet()) const builder = vi.hoisted( () => (options: { @@ -28,12 +29,31 @@ const builder = vi.hoisted( operation: options.operation, useCase: options.useCase, }) - return async () => new Response() + const handler = async () => new Response() + built.add(handler) + return handler + } +) +/** The raw route being invoked, so its admission call can be attributed to it. */ +const invoking = vi.hoisted(() => ({ method: '', path: '' })) +const recordAdmission = vi.hoisted( + () => + async ( + _request: Request, + operation: ApplicationOperation, + _auth: unknown, + _rateLimit: unknown, + useCase?: CopilotRouteUseCase + ) => { + inventory.push({ ...invoking, operation, useCase }) + return { success: false, response: new Response(null, { status: 403 }) } } ) vi.mock('@/lib/api/server/routes/v2-json-route', () => ({ ...apiServerRoutesMock, defineV2JsonRoute: builder, + admitV2Request: recordAdmission, + admitOptionalV2Request: recordAdmission, })) vi.mock('@/lib/api/server/routes/v2-binary-route', () => ({ ...apiServerRoutesMock, @@ -44,6 +64,7 @@ vi.mock('@/lib/api/server/routes/v2-body-lifecycle-route', () => ({ defineV2BodyLifecycleRoute: builder, })) +import { NextRequest } from 'next/server' import { type CopilotRouteUseCase, copilotRouteAudience, @@ -59,9 +80,28 @@ import type { ApplicationOperation } from '@/lib/core/application/operation' */ const REFUSED_ROUTES_FILE = './copilot-refused-routes.json' -it('inventories private operation admission without executing route requests', async () => { - for (const route of V2_ROUTES) await route.load() +const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const + +/** + * A raw route (chat, workflow execute and resume) admits inside its handler, so it is + * invoked once and its admission call recorded, which ends the request before any work. + */ +async function inventoryRawHandlers(pattern: string, module: object): Promise { + for (const method of METHODS) { + const handler: unknown = Reflect.get(module, method) + if (typeof handler !== 'function' || built.has(handler)) continue + invoking.method = method + invoking.path = pattern.replace(/\{([^}]+)\}/g, '[$1]') + await handler(new NextRequest(`http://localhost${invoking.path}`, { method }), { + params: Promise.resolve({}), + }) + } +} + +it('inventories private operation admission without executing use cases', async () => { + for (const route of V2_ROUTES) await inventoryRawHandlers(route.pattern, await route.load()) expect(inventory.length).toBeGreaterThan(200) + expect(inventory.find((route) => route.path === '/api/v2/chat')?.operation.id).toBe('chat.send') const audiences = inventory.map((route) => copilotRouteAudience(route.operation, route.useCase)) /** Public organization and version-history operations require a direct caller. */ const refused = inventory diff --git a/packages/sim-cli/src/commands/protocol/chat.ts b/packages/sim-cli/src/commands/protocol/chat.ts index 7cf4196710a..daea2db8df1 100644 --- a/packages/sim-cli/src/commands/protocol/chat.ts +++ b/packages/sim-cli/src/commands/protocol/chat.ts @@ -7,6 +7,7 @@ import { type ChatResponse, V2_OPERATIONS } from '../../generated/v2-api' import { SimApiError } from '../../http/client' import { readNdjson } from '../../http/ndjson' import { sanitize } from '../../output/render' +import { callsOperations } from '../../runtime/called-operations' import { printProtocolResult } from './result' /** The final payload, as `POST /api/v2/chat` answers it. */ @@ -98,7 +99,7 @@ function ignoreBrokenPipe(stream: NodeJS.WriteStream): () => void { */ export function attachChat(program: Command): void { - program + const chat = program .command('chat') .description('Ask Sim and print the reply') .argument('', 'What to ask Sim') @@ -197,4 +198,5 @@ Examples: restorePipeHandling?.() } }) + callsOperations(chat, ['chat']) } diff --git a/packages/sim-cli/src/contract/reference.test.ts b/packages/sim-cli/src/contract/reference.test.ts index 2c19bdcafb1..6e5704fc2c2 100644 --- a/packages/sim-cli/src/contract/reference.test.ts +++ b/packages/sim-cli/src/contract/reference.test.ts @@ -74,6 +74,8 @@ describe('CLI reference producer', () => { expect(flag('meta status')).toBe(true) expect(flag('whoami')).toBe(true) expect(flag('files versions download')).toBe(true) + expect(flag('chat')).toBe(true) + expect(flag('workflows run')).toBeUndefined() expect(flag('workspaces get')).toBeUndefined() expect(flag('workspaces invitations create')).toBeUndefined() expect(flag('files get')).toBeUndefined()