diff --git a/apps/docs/content/docs/api-reference/meta.json b/apps/docs/content/docs/api-reference/meta.json index c4c431ae0e5..3b8d0a052bf 100644 --- a/apps/docs/content/docs/api-reference/meta.json +++ b/apps/docs/content/docs/api-reference/meta.json @@ -19,6 +19,7 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", + "(generated)/sandboxes", "(generated)/credentials", "(generated)/secrets", "(generated)/billing", diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index 9c12ac1f34c..e8a54f5acc6 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -43,6 +43,7 @@ These apply to every command, and may be written before or after it. | [`sim logs`](/cli/logs) | Manage logs | | [`sim mcp-servers`](/cli/mcp-servers) | Manage mcp servers | | [`sim meta`](/cli/meta) | Manage meta | +| [`sim sandboxes`](/cli/sandboxes) | Manage sandboxes | | [`sim secrets`](/cli/secrets) | Manage secrets | | [`sim skills`](/cli/skills) | Manage skills | | [`sim tables`](/cli/tables) | Manage tables | diff --git a/apps/docs/content/docs/cli/meta.json b/apps/docs/content/docs/cli/meta.json index d90b96413aa..3b9bb713014 100644 --- a/apps/docs/content/docs/cli/meta.json +++ b/apps/docs/content/docs/cli/meta.json @@ -24,6 +24,7 @@ "logs", "mcp-servers", "meta", + "sandboxes", "secrets", "skills", "tables", diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index b6c6cc0b2e5..28bab99672f 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -2741,6 +2741,131 @@ Show what this API supports and which limits apply sim meta status ``` +## sim sandboxes + +Also spelled `sim sandbox`. + +### sim sandboxes create + +Create Sandbox (personal API key required) + +```bash +sim sandboxes create [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | Display name, unique within the workspace; 1 to 64 characters. | +| `--language ` | Yes | Dependency ecosystem: `javascript` installs from npm, `python` from PyPI. Accepted values: `javascript`, `python`. | +| `--dependencies ` | No | Package specifiers installed into the sandbox, one per entry. (space-separated, or @path / @- with one value per line; in a file, blank lines and # comments are ignored, while inline values are sent as typed and may not be empty; @@value for a literal leading @). | +| `--cli-tools ` | No | Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--system-packages ` | No | Debian packages installed into the sandbox, one per entry. (space-separated, or @path / @- with one value per line; in a file, blank lines and # comments are ignored, while inline values are sent as typed and may not be empty; @@value for a literal leading @). | + + + +### sim sandboxes delete + +Delete Sandbox (personal API key required) + +```bash +sim sandboxes delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `sandboxId` | Yes | Unique sandbox identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim sandboxes get + +Get Sandbox + +```bash +sim sandboxes get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `sandboxId` | Yes | Unique sandbox identifier. | + + + +### sim sandboxes list + +List Sandboxes + +```bash +sim sandboxes list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the sandbox name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +### sim sandboxes update + +Update Sandbox (personal API key required) + +```bash +sim sandboxes update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `sandboxId` | Yes | Unique sandbox identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | New display name, unique within the workspace; 1 to 64 characters. | +| `--language ` | No | Replacement dependency ecosystem. The whole spec is revalidated against it, so a Python dependency list does not survive a switch to JavaScript. Accepted values: `javascript`, `python`. | +| `--dependencies ` | No | Replacement package list; replaces the whole list. (space-separated, or @path / @- with one value per line; in a file, blank lines and # comments are ignored, while inline values are sent as typed and may not be empty; @@value for a literal leading @). | +| `--cli-tools ` | No | Replacement managed CLI list; replaces the whole list. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--system-packages ` | No | Replacement Debian package list; replaces the whole list. (space-separated, or @path / @- with one value per line; in a file, blank lines and # comments are ignored, while inline values are sent as typed and may not be empty; @@value for a literal leading @). | + + + ## sim secrets Also spelled `sim secret`. diff --git a/apps/docs/content/docs/cli/sandboxes.mdx b/apps/docs/content/docs/cli/sandboxes.mdx new file mode 100644 index 00000000000..9a73cc47dae --- /dev/null +++ b/apps/docs/content/docs/cli/sandboxes.mdx @@ -0,0 +1,127 @@ +--- +title: Sandboxes +description: Manage sandboxes — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +`sim sandboxes` is also spelled `sim sandbox`. + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Create sandbox + +```bash +sim sandboxes create [options] +``` + +Create Sandbox (personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | Display name, unique within the workspace; 1 to 64 characters. | +| `--language ` | Yes | Dependency ecosystem: `javascript` installs from npm, `python` from PyPI. Accepted values: `javascript`, `python`. | +| `--dependencies ` | No | Package specifiers installed into the sandbox, one per entry. (space-separated, or @path / @- with one value per line; in a file, blank lines and # comments are ignored, while inline values are sent as typed and may not be empty; @@value for a literal leading @). | +| `--cli-tools ` | No | Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--system-packages ` | No | Debian packages installed into the sandbox, one per entry. (space-separated, or @path / @- with one value per line; in a file, blank lines and # comments are ignored, while inline values are sent as typed and may not be empty; @@value for a literal leading @). | + + + +## Delete sandbox + +```bash +sim sandboxes delete [options] +``` + +Delete Sandbox (personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `sandboxId` | Yes | Unique sandbox identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Get sandbox + +```bash +sim sandboxes get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `sandboxId` | Yes | Unique sandbox identifier. | + + + +## List sandboxes + +```bash +sim sandboxes list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the sandbox name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +## Update sandbox + +```bash +sim sandboxes update [options] +``` + +Update Sandbox (personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `sandboxId` | Yes | Unique sandbox identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | New display name, unique within the workspace; 1 to 64 characters. | +| `--language ` | No | Replacement dependency ecosystem. The whole spec is revalidated against it, so a Python dependency list does not survive a switch to JavaScript. Accepted values: `javascript`, `python`. | +| `--dependencies ` | No | Replacement package list; replaces the whole list. (space-separated, or @path / @- with one value per line; in a file, blank lines and # comments are ignored, while inline values are sent as typed and may not be empty; @@value for a literal leading @). | +| `--cli-tools ` | No | Replacement managed CLI list; replaces the whole list. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--system-packages ` | No | Replacement Debian package list; replaces the whole list. (space-separated, or @path / @- with one value per line; in a file, blank lines and # comments are ignored, while inline values are sent as typed and may not be empty; @@value for a literal leading @). | + + diff --git a/apps/docs/content/docs/platform/enterprise/access-control.mdx b/apps/docs/content/docs/platform/enterprise/access-control.mdx index 929f0f56437..8914a18519f 100644 --- a/apps/docs/content/docs/platform/enterprise/access-control.mdx +++ b/apps/docs/content/docs/platform/enterprise/access-control.mdx @@ -97,6 +97,7 @@ Controls the modules, actions, and credentials available to group members. Every |---------|---------------------------| | Chat | Revokes Chat. Members cannot ask Sim to build or edit anything. | | Sim Mailer | Revokes the Sim Mailer inbox. Members cannot read or send mail. | +| Sandboxes | Revokes the Sandboxes module. Members cannot view, create, or change a workspace sandbox. | **Knowledge Base** diff --git a/apps/docs/lib/openapi-download.test.ts b/apps/docs/lib/openapi-download.test.ts index e424d596078..48b81490491 100644 --- a/apps/docs/lib/openapi-download.test.ts +++ b/apps/docs/lib/openapi-download.test.ts @@ -33,7 +33,7 @@ describe('OpenAPI download', () => { const tags = document.tags as Array<{ name: string }> expect(document.openapi).toBe('3.1.0') - expect(Object.keys(paths)).toHaveLength(130) + expect(Object.keys(paths)).toHaveLength(132) expect(tags.map((tag) => tag.name)).toEqual([ 'Workflows', 'Workflow Runs', @@ -48,6 +48,7 @@ describe('OpenAPI download', () => { 'MCP Servers', 'Skills', 'Custom Tools', + 'Sandboxes', 'Credentials', 'Secrets', 'Catalog', diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index b94dd9b6a0d..d6556294305 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Workspace Resources", - "description": "Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, credentials, write-only secrets, and the block, tool, and connector-type catalogs.", + "description": "Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, sandboxes, credentials, write-only secrets, and the block, tool, and connector-type catalogs.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -41,6 +41,10 @@ "name": "Custom Tools", "description": "Create and manage code-backed tools that agents can call." }, + { + "name": "Sandboxes", + "description": "Create and manage the reusable dependency sets that Function blocks execute against." + }, { "name": "Credentials", "description": "Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material." @@ -2011,54 +2015,32 @@ } } }, - "/api/v2/credentials": { + "/api/v2/sandboxes": { "get": { - "operationId": "listCredentials", - "summary": "List Credentials", - "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned.", - "tags": ["Credentials"], + "operationId": "listSandboxes", + "summary": "List Sandboxes", + "description": "List the sandboxes defined in a workspace, with opaque cursor pagination. A sandbox is a reusable dependency set — npm or PyPI packages, pinned managed CLIs, and Debian packages — that Function blocks execute against. Listing is not plan-gated, so a workspace that dropped below the Max tier still sees what it built.", + "tags": ["Sandboxes"], "parameters": [ { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace whose credentials should be listed.", + "description": "Workspace that owns the sandbox.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace whose credentials should be listed." - } - }, - { - "name": "type", - "in": "query", - "required": false, - "description": "Restrict results to this credential type.", - "schema": { - "description": "Restrict results to this credential type.", - "type": "string", - "enum": ["oauth", "service_account"] - } - }, - { - "name": "providerId", - "in": "query", - "required": false, - "description": "Restrict results to credentials for this integration provider.", - "schema": { - "description": "Restrict results to credentials for this integration provider.", - "type": "string", - "minLength": 1 + "description": "Workspace that owns the sandbox." } }, { "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring match against the credential display name.", + "description": "Case-insensitive substring match against the sandbox name.", "schema": { - "description": "Case-insensitive substring match against the credential display name.", + "description": "Case-insensitive substring match against the sandbox name.", "type": "string", "minLength": 1, "maxLength": 200 @@ -2068,12 +2050,12 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { - "default": "createdAt", - "description": "Field used to sort the result.", + "default": "name", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", - "enum": ["displayName", "createdAt", "updatedAt"] + "enum": ["name", "createdAt", "updatedAt"] } }, { @@ -2082,7 +2064,7 @@ "required": false, "description": "Sort direction.", "schema": { - "default": "desc", + "default": "asc", "description": "Sort direction.", "type": "string", "enum": ["asc", "desc"] @@ -2092,10 +2074,10 @@ "name": "limit", "in": "query", "required": false, - "description": "Maximum credentials to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "description": "Maximum sandboxes to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { "default": 50, - "description": "Maximum credentials to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "description": "Maximum sandboxes to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "type": "integer", "minimum": 1, "maximum": 100 @@ -2115,7 +2097,7 @@ ], "responses": { "200": { - "description": "Credentials visible to the caller.", + "description": "Sandboxes defined in the workspace.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2130,7 +2112,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListCredentialsResponse" + "$ref": "#/components/schemas/ListSandboxesResponse" } } } @@ -2159,45 +2141,24 @@ } }, "post": { - "operationId": "createServiceAccountCredential", - "summary": "Create Service-Account Credential", - "description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider, then encode its required fields as the JSON object string in credentials. The credentials string is write-only and is never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Credentials"], + "operationId": "createSandbox", + "summary": "Create Sandbox", + "description": "Create a sandbox. The name must be unique within the workspace. Where the deployment prebuilds dependency images, the build is scheduled and reported through `buildStatus`; a deployment that installs at run time, or a sandbox with nothing to install, has no build and reports `buildStatus: null`. A dependency or system-package entry the builder cannot accept is a `400` whose `error.details` names the field and the offending entries. Requires a workspace admin on a Max or Enterprise plan; a lower plan is refused with `403` and `error.details.code` `WORKSPACE_PLAN_CAPABILITY_REQUIRED`. Creates and updates in a workspace share one write budget, whatever the install strategy, and a burst is refused with `429` and a `Retry-After` header. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Sandboxes"], "requestBody": { "required": true, - "description": "Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery.", + "description": "Name, language, and dependency set of a new sandbox.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateServiceAccountCredentialRequest" + "$ref": "#/components/schemas/CreateSandboxRequest" } } } }, "responses": { - "200": { - "description": "An existing credential matched the verified source.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateServiceAccountCredentialResponse" - } - } - } - }, "201": { - "description": "The service-account credential was created.", + "description": "The sandbox was created; a build is scheduled where the deployment prebuilds images.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2212,7 +2173,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateServiceAccountCredentialResponse" + "$ref": "#/components/schemas/CreateSandboxResponse" } } } @@ -2250,41 +2211,40 @@ } } }, - "/api/v2/credentials/providers": { + "/api/v2/sandboxes/{sandboxId}": { "get": { - "operationId": "listCredentialProviders", - "summary": "List Credential Providers", - "description": "List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. Optionally search provider names with a case-insensitive substring match. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. The bounded set is returned in one page; `nextCursor` is always null.", - "tags": ["Credentials"], + "operationId": "getSandbox", + "summary": "Get Sandbox", + "description": "Fetch one sandbox by identifier, scoped to its workspace, including its current build state and any build failure.", + "tags": ["Sandboxes"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "sandboxId", + "in": "path", "required": true, - "description": "Workspace used to evaluate credential-provider availability and integration policy.", + "description": "Unique sandbox identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace used to evaluate credential-provider availability and integration policy." + "description": "Unique sandbox identifier." } }, { - "name": "search", + "name": "workspaceId", "in": "query", - "required": false, - "description": "Case-insensitive substring match against the credential provider name.", + "required": true, + "description": "Workspace that owns the sandbox.", "schema": { - "description": "Case-insensitive substring match against the credential provider name.", "type": "string", "minLength": 1, - "maxLength": 200 + "maxLength": 128, + "description": "Workspace that owns the sandbox." } } ], "responses": { "200": { - "description": "Credential provider catalog with caller-specific availability.", + "description": "The sandbox.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2299,7 +2259,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListCredentialProvidersResponse" + "$ref": "#/components/schemas/GetSandboxResponse" } } } @@ -2326,28 +2286,39 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/credentials/connections": { - "post": { - "operationId": "createCredentialConnection", - "summary": "Create Credential Connection", - "description": "Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Credentials"], + }, + "patch": { + "operationId": "updateSandbox", + "summary": "Update Sandbox", + "description": "Update the supplied sandbox fields. Omitted fields retain their stored values; a supplied list replaces the whole list; names must remain unique within the workspace. Where the deployment prebuilds dependency images, a changed spec is rebuilt and re-sending an unchanged spec after a failed build retries it; a deployment that installs at run time, or a spec with nothing to install, has no build and reports `buildStatus: null`. Requires a workspace admin on a Max or Enterprise plan; a lower plan is refused with `403` and `error.details.code` `WORKSPACE_PLAN_CAPABILITY_REQUIRED`. Creates and updates in a workspace share one write budget, whatever the install strategy, and a burst is refused with `429` and a `Retry-After` header. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Sandboxes"], + "parameters": [ + { + "name": "sandboxId", + "in": "path", + "required": true, + "description": "Unique sandbox identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique sandbox identifier." + } + } + ], "requestBody": { "required": true, - "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.", + "description": "Sandbox fields to change; at least one editable field is required.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateCredentialConnectionBody" + "$ref": "#/components/schemas/UpdateSandboxRequest" } } } }, "responses": { "200": { - "description": "A short-lived browser authorization URL.", + "description": "The updated sandbox.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2362,7 +2333,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateCredentialConnectionResponse" + "$ref": "#/components/schemas/UpdateSandboxResponse" } } } @@ -2398,43 +2369,40 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/credentials/{credentialId}": { + }, "delete": { - "operationId": "deleteCredential", - "summary": "Disconnect Credential", - "description": "Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Credentials"], + "operationId": "deleteSandbox", + "summary": "Delete Sandbox", + "description": "Delete a sandbox. Function blocks that still select it fail closed at run time until they are re-pointed. Where the deployment prebuilds dependency images, the sandbox's image is released once nothing else shares it; a runtime-install deployment, or a spec with nothing to install, had no image and nothing is released. Requires a workspace admin on a Max or Enterprise plan; a lower plan is refused with `403` and `error.details.code` `WORKSPACE_PLAN_CAPABILITY_REQUIRED`. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Sandboxes"], "parameters": [ { - "name": "credentialId", + "name": "sandboxId", "in": "path", "required": true, - "description": "Credential to disconnect.", + "description": "Unique sandbox identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 255, - "description": "Credential to disconnect." + "description": "Unique sandbox identifier." } }, { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace expected to own the credential.", + "description": "Workspace that owns the sandbox.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace expected to own the credential." + "description": "Workspace that owns the sandbox." } } ], "responses": { "200": { - "description": "The credential was disconnected.", + "description": "The sandbox was deleted.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2449,7 +2417,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteCredentialResponse" + "$ref": "#/components/schemas/DeleteSandboxResponse" } } } @@ -2476,52 +2444,113 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "patch": { - "operationId": "updateCredential", - "summary": "Update Credential", - "description": "Rotate a service-account credential's secret material, or rename it. Send only the fields to change: an omitted field is left unchanged, and `description: null` clears the stored description. Secret fields are write-only and are never returned, and only a service-account credential has any: sending one for a credential of another type answers `400` rather than dropping it. The provider re-verifies replacement secret material before it replaces the stored secret, so a rejected secret leaves the stored one untouched and answers `400` with the provider's code in `error.details.providerErrorCode`; a provider that cannot be reached answers `503`. The credential ID is preserved, so every workflow, deployment, paused run, knowledge connector, and webhook that references it keeps working — which disconnecting and re-creating does not. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + } + }, + "/api/v2/credentials": { + "get": { + "operationId": "listCredentials", + "summary": "List Credentials", + "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned.", "tags": ["Credentials"], "parameters": [ { - "name": "credentialId", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "Credential to update.", + "description": "Workspace whose credentials should be listed.", "schema": { "type": "string", "minLength": 1, - "maxLength": 255, - "description": "Credential to update." + "maxLength": 128, + "description": "Workspace whose credentials should be listed." } }, { - "name": "workspaceId", + "name": "type", "in": "query", - "required": true, - "description": "Workspace expected to own the credential.", + "required": false, + "description": "Restrict results to this credential type.", + "schema": { + "description": "Restrict results to this credential type.", + "type": "string", + "enum": ["oauth", "service_account"] + } + }, + { + "name": "providerId", + "in": "query", + "required": false, + "description": "Restrict results to credentials for this integration provider.", "schema": { + "description": "Restrict results to credentials for this integration provider.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the credential display name.", + "schema": { + "description": "Case-insensitive substring match against the credential display name.", "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace expected to own the credential." + "maxLength": 200 } - } - ], - "requestBody": { - "required": true, - "description": "Replacement display metadata and the write-only fields declared by provider discovery.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateCredentialRequest" - } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "createdAt", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["displayName", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum credentials to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum credentials to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 } } - }, + ], "responses": { "200": { - "description": "The updated credential without secret material.", + "description": "Credentials visible to the caller.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2536,7 +2565,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateCredentialResponse" + "$ref": "#/components/schemas/ListCredentialsResponse" } } } @@ -2553,8 +2582,90 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "createServiceAccountCredential", + "summary": "Create Service-Account Credential", + "description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider, then encode its required fields as the JSON object string in credentials. The credentials string is write-only and is never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "requestBody": { + "required": true, + "description": "Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialRequest" + } + } + } + }, + "responses": { + "200": { + "description": "An existing credential matched the verified source.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialResponse" + } + } + } + }, + "201": { + "description": "The service-account credential was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" }, "413": { "$ref": "#/components/responses/PayloadTooLarge" @@ -2574,100 +2685,41 @@ } } }, - "/api/v2/secrets": { + "/api/v2/credentials/providers": { "get": { - "operationId": "listSecrets", - "summary": "List Secrets", - "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Rows for workspace secrets marked visible (unredacted) include the stored value; every other row is metadata-only and no other response ever carries a value. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], + "operationId": "listCredentialProviders", + "summary": "List Credential Providers", + "description": "List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. Optionally search provider names with a case-insensitive substring match. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. The bounded set is returned in one page; `nextCursor` is always null.", + "tags": ["Credentials"], "parameters": [ { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace whose secret metadata should be listed.", + "description": "Workspace used to evaluate credential-provider availability and integration policy.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace whose secret metadata should be listed." - } - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "Restrict results to one ownership scope.", - "schema": { - "description": "Restrict results to one ownership scope.", - "type": "string", - "enum": ["workspace", "personal"] + "description": "Workspace used to evaluate credential-provider availability and integration policy." } }, { "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring match against the secret name.", + "description": "Case-insensitive substring match against the credential provider name.", "schema": { - "description": "Case-insensitive substring match against the secret name.", + "description": "Case-insensitive substring match against the credential provider name.", "type": "string", "minLength": 1, "maxLength": 200 } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "name", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": ["name", "createdAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 - } } ], "responses": { "200": { - "description": "Secret metadata visible to the caller.", + "description": "Credential provider catalog with caller-specific availability.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2682,7 +2734,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListSecretsResponse" + "$ref": "#/components/schemas/ListCredentialProvidersResponse" } } } @@ -2711,62 +2763,26 @@ } } }, - "/api/v2/secrets/{name}": { - "put": { - "operationId": "setSecret", - "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. Omit `value` on a workspace secret to update `description` and `unredacted` alone: the stored value is left untouched and is never re-encrypted, and because a metadata-only write cannot create a secret it answers `404` when the named secret does not exist. A personal secret always requires `value`, having no other writable field. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "description": "Secret to create or replace.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create or replace." - } - } - ], + "/api/v2/credentials/connections": { + "post": { + "operationId": "createCredentialConnection", + "summary": "Create Credential Connection", + "description": "Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], "requestBody": { "required": true, - "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetSecretRequest" + "$ref": "#/components/schemas/CreateCredentialConnectionBody" } } } }, "responses": { "200": { - "description": "The existing secret value was replaced, or its metadata was updated in place.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetSecretResponse" - } - } - } - }, - "201": { - "description": "The secret was created.", + "description": "A short-lived browser authorization URL.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2781,7 +2797,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetSecretResponse" + "$ref": "#/components/schemas/CreateCredentialConnectionResponse" } } } @@ -2798,6 +2814,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -2814,53 +2833,43 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, + } + }, + "/api/v2/credentials/{credentialId}": { "delete": { - "operationId": "deleteSecret", - "summary": "Delete Secret", - "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], + "operationId": "deleteCredential", + "summary": "Disconnect Credential", + "description": "Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], "parameters": [ { - "name": "name", + "name": "credentialId", "in": "path", "required": true, - "description": "Secret to delete.", + "description": "Credential to disconnect.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to delete." + "description": "Credential to disconnect." } }, { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces.", + "description": "Workspace expected to own the credential.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces." - } - }, - { - "name": "scope", - "in": "query", - "required": true, - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace.", - "schema": { - "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + "description": "Workspace expected to own the credential." } } ], "responses": { "200": { - "description": "The secret was deleted.", + "description": "The credential was disconnected.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2875,7 +2884,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteSecretResponse" + "$ref": "#/components/schemas/DeleteCredentialResponse" } } } @@ -2902,17 +2911,52 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/meta": { - "get": { - "operationId": "getApiMeta", - "summary": "Get API Capabilities", - "description": "Report whether v2 is available, whether the calling API key is personal or workspace-scoped, and when it expires. Requires a valid key.", - "tags": ["Meta"], + }, + "patch": { + "operationId": "updateCredential", + "summary": "Update Credential", + "description": "Rotate a service-account credential's secret material, or rename it. Send only the fields to change: an omitted field is left unchanged, and `description: null` clears the stored description. Secret fields are write-only and are never returned, and only a service-account credential has any: sending one for a credential of another type answers `400` rather than dropping it. The provider re-verifies replacement secret material before it replaces the stored secret, so a rejected secret leaves the stored one untouched and answers `400` with the provider's code in `error.details.providerErrorCode`; a provider that cannot be reached answers `503`. The credential ID is preserved, so every workflow, deployment, paused run, knowledge connector, and webhook that references it keeps working — which disconnecting and re-creating does not. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "parameters": [ + { + "name": "credentialId", + "in": "path", + "required": true, + "description": "Credential to update.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential to update." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace expected to own the credential.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace expected to own the credential." + } + } + ], + "requestBody": { + "required": true, + "description": "Replacement display metadata and the write-only fields declared by provider discovery.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCredentialRequest" + } + } + } + }, "responses": { "200": { - "description": "Availability and lifecycle facts about the calling key.", + "description": "The updated credential without secret material.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2927,7 +2971,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetApiMetaResponse" + "$ref": "#/components/schemas/UpdateCredentialResponse" } } } @@ -2938,6 +2982,21 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2950,23 +3009,46 @@ } } }, - "/api/v2/workflow-mcp-servers": { + "/api/v2/secrets": { "get": { - "operationId": "listWorkflowMcpServers", - "summary": "List Workflow MCP Servers", - "description": "List the MCP servers a workspace *publishes*. These serve deployed workflows as tools to outside MCP clients, which is the opposite direction from `GET /api/v2/mcp-servers` — that lists external servers Sim calls. Each entry carries the endpoint clients connect to and the tool names it exposes; those names are gathered under a 2,000-tool budget shared across the page, so on a page of unusually large servers the trailing entries can list fewer names than they publish. Read one server's full inventory with `GET /api/v2/workflow-mcp-servers/{serverId}/tools`. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["MCP Servers"], + "operationId": "listSecrets", + "summary": "List Secrets", + "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Rows for workspace secrets marked visible (unredacted) include the stored value; every other row is metadata-only and no other response ever carries a value. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], "parameters": [ { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace whose published MCP servers to list.", + "description": "Workspace whose secret metadata should be listed.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace whose published MCP servers to list." + "description": "Workspace whose secret metadata should be listed." + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Restrict results to one ownership scope.", + "schema": { + "description": "Restrict results to one ownership scope.", + "type": "string", + "enum": ["workspace", "personal"] + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the secret name.", + "schema": { + "description": "Case-insensitive substring match against the secret name.", + "type": "string", + "minLength": 1, + "maxLength": 200 } }, { @@ -2975,7 +3057,7 @@ "required": false, "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { - "default": "createdAt", + "default": "name", "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] @@ -2987,7 +3069,7 @@ "required": false, "description": "Sort direction.", "schema": { - "default": "desc", + "default": "asc", "description": "Sort direction.", "type": "string", "enum": ["asc", "desc"] @@ -2997,10 +3079,10 @@ "name": "limit", "in": "query", "required": false, - "description": "Maximum workflow-MCP servers to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { "default": 50, - "description": "Maximum workflow-MCP servers to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "type": "integer", "minimum": 1, "maximum": 100 @@ -3020,7 +3102,7 @@ ], "responses": { "200": { - "description": "A page of published MCP servers.", + "description": "Secret metadata visible to the caller.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3035,7 +3117,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListWorkflowMcpServersResponse" + "$ref": "#/components/schemas/ListSecretsResponse" } } } @@ -3062,26 +3144,64 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "post": { - "operationId": "createWorkflowMcpServer", - "summary": "Create Workflow MCP Server", - "description": "Publish a new MCP server for a workspace, optionally seeding it with workflows to expose as tools. Every workflow named in `workflowIds` must already be deployed. Setting `isPublic` lets any MCP client holding the server URL execute the workflows it publishes without a Sim API key. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["MCP Servers"], + } + }, + "/api/v2/secrets/{name}": { + "put": { + "operationId": "setSecret", + "summary": "Set Secret", + "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. Omit `value` on a workspace secret to update `description` and `unredacted` alone: the stored value is left untouched and is never re-encrypted, and because a metadata-only write cannot create a secret it answers `404` when the named secret does not exist. A personal secret always requires `value`, having no other writable field. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Secret to create or replace.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret to create or replace." + } + } + ], "requestBody": { "required": true, - "description": "A new workspace-published MCP server and the workflows it exposes.", + "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWorkflowMcpServerRequest" + "$ref": "#/components/schemas/SetSecretRequest" } } } }, "responses": { + "200": { + "description": "The existing secret value was replaced, or its metadata was updated in place.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetSecretResponse" + } + } + } + }, "201": { - "description": "The published MCP server.", + "description": "The secret was created.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3096,7 +3216,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWorkflowMcpServerResponse" + "$ref": "#/components/schemas/SetSecretResponse" } } } @@ -3113,9 +3233,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -3132,30 +3249,53 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/workflow-mcp-servers/{serverId}": { - "get": { - "operationId": "getWorkflowMcpServer", - "summary": "Get Workflow MCP Server", - "description": "Read one published MCP server. The list is the only other place this state is published, so a caller holding a server id would otherwise have to page the collection and filter client-side. The tools it publishes are on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["MCP Servers"], + }, + "delete": { + "operationId": "deleteSecret", + "summary": "Delete Secret", + "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], "parameters": [ { - "name": "serverId", + "name": "name", "in": "path", "required": true, - "description": "Unique workflow-MCP server identifier.", + "description": "Secret to delete.", "schema": { "type": "string", "minLength": 1, - "description": "Unique workflow-MCP server identifier." + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret to delete." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces." + } + }, + { + "name": "scope", + "in": "query", + "required": true, + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace.", + "schema": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." } } ], "responses": { "200": { - "description": "The MCP server.", + "description": "The secret was deleted.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3170,7 +3310,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetWorkflowMcpServerResponse" + "$ref": "#/components/schemas/DeleteSecretResponse" } } } @@ -3197,39 +3337,125 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "patch": { - "operationId": "updateWorkflowMcpServer", - "summary": "Update Workflow MCP Server", - "description": "Rename, re-describe, or change the public visibility of a published MCP server. Merge-patch shaped: an omitted key is unchanged and `description: null` clears the description. Publishing and unpublishing the workflows it serves are separate operations on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["MCP Servers"], - "parameters": [ - { - "name": "serverId", - "in": "path", - "required": true, - "description": "Unique workflow-MCP server identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Unique workflow-MCP server identifier." - } - } - ], - "requestBody": { - "required": true, - "description": "Merge-patch body for a published MCP server.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateWorkflowMcpServerRequest" + } + }, + "/api/v2/meta": { + "get": { + "operationId": "getApiMeta", + "summary": "Get API Capabilities", + "description": "Report whether v2 is available, whether the calling API key is personal or workspace-scoped, and when it expires. Requires a valid key.", + "tags": ["Meta"], + "responses": { + "200": { + "description": "Availability and lifecycle facts about the calling key.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetApiMetaResponse" + } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, + } + } + }, + "/api/v2/workflow-mcp-servers": { + "get": { + "operationId": "listWorkflowMcpServers", + "summary": "List Workflow MCP Servers", + "description": "List the MCP servers a workspace *publishes*. These serve deployed workflows as tools to outside MCP clients, which is the opposite direction from `GET /api/v2/mcp-servers` — that lists external servers Sim calls. Each entry carries the endpoint clients connect to and the tool names it exposes; those names are gathered under a 2,000-tool budget shared across the page, so on a page of unusually large servers the trailing entries can list fewer names than they publish. Read one server's full inventory with `GET /api/v2/workflow-mcp-servers/{serverId}/tools`. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose published MCP servers to list.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose published MCP servers to list." + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "createdAt", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum workflow-MCP servers to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum workflow-MCP servers to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], "responses": { "200": { - "description": "The updated MCP server.", + "description": "A page of published MCP servers.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3244,7 +3470,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateWorkflowMcpServerResponse" + "$ref": "#/components/schemas/ListWorkflowMcpServersResponse" } } } @@ -3261,15 +3487,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3281,27 +3498,25 @@ } } }, - "delete": { - "operationId": "deleteWorkflowMcpServer", - "summary": "Delete Workflow MCP Server", - "description": "Unpublish an MCP server. Every tool it served stops answering and connected clients lose the endpoint. The workflows themselves are untouched — their own deployments stay live and executable through the workflow API. A workspace API key is rejected with `403`; use a personal API key.", + "post": { + "operationId": "createWorkflowMcpServer", + "summary": "Create Workflow MCP Server", + "description": "Publish a new MCP server for a workspace, optionally seeding it with workflows to expose as tools. Every workflow named in `workflowIds` must already be deployed. Setting `isPublic` lets any MCP client holding the server URL execute the workflows it publishes without a Sim API key. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["MCP Servers"], - "parameters": [ - { - "name": "serverId", - "in": "path", - "required": true, - "description": "Unique workflow-MCP server identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Unique workflow-MCP server identifier." + "requestBody": { + "required": true, + "description": "A new workspace-published MCP server and the workflows it exposes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkflowMcpServerRequest" + } } } - ], + }, "responses": { - "200": { - "description": "The MCP server was unpublished.", + "201": { + "description": "The published MCP server.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3316,7 +3531,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteWorkflowMcpServerResponse" + "$ref": "#/components/schemas/CreateWorkflowMcpServerResponse" } } } @@ -3336,6 +3551,12 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3348,11 +3569,11 @@ } } }, - "/api/v2/workflow-mcp-servers/{serverId}/tools": { + "/api/v2/workflow-mcp-servers/{serverId}": { "get": { - "operationId": "listWorkflowMcpTools", - "summary": "List Workflow MCP Tools", - "description": "Every tool a server publishes, tool-name ordered. The server list reports tool *names* only, so this is where a caller reads the `workflowId` that `DELETE /api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}` addresses. Returned in one page rather than paged — so `nextCursor` is always null — and capped at 2,000 tools, which is far above any real server's inventory. A workspace API key is rejected with `403`; use a personal API key.", + "operationId": "getWorkflowMcpServer", + "summary": "Get Workflow MCP Server", + "description": "Read one published MCP server. The list is the only other place this state is published, so a caller holding a server id would otherwise have to page the collection and filter client-side. The tools it publishes are on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["MCP Servers"], "parameters": [ { @@ -3369,7 +3590,7 @@ ], "responses": { "200": { - "description": "The tools this server publishes.", + "description": "The MCP server.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3384,7 +3605,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListWorkflowMcpToolsResponse" + "$ref": "#/components/schemas/GetWorkflowMcpServerResponse" } } } @@ -3412,10 +3633,10 @@ } } }, - "post": { - "operationId": "deployWorkflowMcpTool", - "summary": "Publish Workflow As MCP Tool", - "description": "Publish a deployed workflow as a tool on an MCP server. The tool's input schema is generated from the deployed workflow's input format, so the workflow must already be deployed. Idempotent per workflow: a server carries at most one tool per workflow, so a repeat call replaces the existing tool and answers `200` with `updated: true` rather than conflicting. A workspace API key is rejected with `403`; use a personal API key.", + "patch": { + "operationId": "updateWorkflowMcpServer", + "summary": "Update Workflow MCP Server", + "description": "Rename, re-describe, or change the public visibility of a published MCP server. Merge-patch shaped: an omitted key is unchanged and `description: null` clears the description. Publishing and unpublishing the workflows it serves are separate operations on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["MCP Servers"], "parameters": [ { @@ -3432,18 +3653,18 @@ ], "requestBody": { "required": true, - "description": "The workflow to publish and the tool metadata MCP clients see.", + "description": "Merge-patch body for a published MCP server.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeployWorkflowMcpToolRequest" + "$ref": "#/components/schemas/UpdateWorkflowMcpServerRequest" } } } }, "responses": { "200": { - "description": "The published tool.", + "description": "The updated MCP server.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3458,7 +3679,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeployWorkflowMcpToolResponse" + "$ref": "#/components/schemas/UpdateWorkflowMcpServerResponse" } } } @@ -3494,13 +3715,11 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}": { + }, "delete": { - "operationId": "undeployWorkflowMcpTool", - "summary": "Unpublish Workflow MCP Tool", - "description": "Remove a workflow from an MCP server. Addressed by workflow rather than by tool identifier, because a server carries at most one live tool per workflow. The workflow's own deployment is untouched. A workspace API key is rejected with `403`; use a personal API key.", + "operationId": "deleteWorkflowMcpServer", + "summary": "Delete Workflow MCP Server", + "description": "Unpublish an MCP server. Every tool it served stops answering and connected clients lose the endpoint. The workflows themselves are untouched — their own deployments stay live and executable through the workflow API. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["MCP Servers"], "parameters": [ { @@ -3513,22 +3732,11 @@ "minLength": 1, "description": "Unique workflow-MCP server identifier." } - }, - { - "name": "workflowId", - "in": "path", - "required": true, - "description": "Workflow published as a tool on this server.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Workflow published as a tool on this server." - } } ], "responses": { "200": { - "description": "The tool was removed.", + "description": "The MCP server was unpublished.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3543,7 +3751,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UndeployWorkflowMcpToolResponse" + "$ref": "#/components/schemas/DeleteWorkflowMcpServerResponse" } } } @@ -3575,35 +3783,262 @@ } } }, - "/api/v2/blocks": { + "/api/v2/workflow-mcp-servers/{serverId}/tools": { "get": { - "operationId": "listBlocks", - "summary": "List Blocks", - "description": "List the blocks available in a workspace, built-in and workspace-deployed alike, discriminated by `source`. Availability is caller-specific: the workspace’s integration allowlist, the organization’s revealed preview blocks, and the deployment’s allowlist all narrow the result. Use `capability=trigger` for the blocks that can start a workflow. Summaries name their tools and operations by id — resolve one with Get Block or Get Tool.", - "tags": ["Catalog"], + "operationId": "listWorkflowMcpTools", + "summary": "List Workflow MCP Tools", + "description": "Every tool a server publishes, tool-name ordered. The server list reports tool *names* only, so this is where a caller reads the `workflowId` that `DELETE /api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}` addresses. Returned in one page rather than paged — so `nextCursor` is always null — and capped at 2,000 tools, which is far above any real server's inventory. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "serverId", + "in": "path", "required": true, - "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." - } - }, - { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the block id, name, and description.", + "description": "Unique workflow-MCP server identifier.", "schema": { - "description": "Case-insensitive substring match against the block id, name, and description.", "type": "string", "minLength": 1, - "maxLength": 200 + "description": "Unique workflow-MCP server identifier." + } + } + ], + "responses": { + "200": { + "description": "The tools this server publishes.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListWorkflowMcpToolsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "deployWorkflowMcpTool", + "summary": "Publish Workflow As MCP Tool", + "description": "Publish a deployed workflow as a tool on an MCP server. The tool's input schema is generated from the deployed workflow's input format, so the workflow must already be deployed. Idempotent per workflow: a server carries at most one tool per workflow, so a repeat call replaces the existing tool and answers `200` with `updated: true` rather than conflicting. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "description": "Unique workflow-MCP server identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow-MCP server identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "The workflow to publish and the tool metadata MCP clients see.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployWorkflowMcpToolRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The published tool.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployWorkflowMcpToolResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}": { + "delete": { + "operationId": "undeployWorkflowMcpTool", + "summary": "Unpublish Workflow MCP Tool", + "description": "Remove a workflow from an MCP server. Addressed by workflow rather than by tool identifier, because a server carries at most one live tool per workflow. The workflow's own deployment is untouched. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["MCP Servers"], + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "description": "Unique workflow-MCP server identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow-MCP server identifier." + } + }, + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "Workflow published as a tool on this server.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workflow published as a tool on this server." + } + } + ], + "responses": { + "200": { + "description": "The tool was removed.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UndeployWorkflowMcpToolResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/blocks": { + "get": { + "operationId": "listBlocks", + "summary": "List Blocks", + "description": "List the blocks available in a workspace, built-in and workspace-deployed alike, discriminated by `source`. Availability is caller-specific: the workspace’s integration allowlist, the organization’s revealed preview blocks, and the deployment’s allowlist all narrow the result. Use `capability=trigger` for the blocks that can start a workflow. Summaries name their tools and operations by id — resolve one with Get Block or Get Tool.", + "tags": ["Catalog"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the block id, name, and description.", + "schema": { + "description": "Case-insensitive substring match against the block id, name, and description.", + "type": "string", + "minLength": 1, + "maxLength": 200 } }, { @@ -4339,7 +4774,7 @@ "example": { "error": { "code": "CONFLICT", - "message": "API key name already exists" + "message": "The request conflicts with the current state of the resource" } } } @@ -6353,6 +6788,517 @@ } ] }, + "V2Sandbox": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique sandbox identifier." + }, + "name": { + "type": "string", + "description": "Display name, unique within the workspace." + }, + "language": { + "type": "string", + "enum": ["javascript", "python"], + "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." + }, + "dependencies": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Package specifiers installed into the sandbox, one per entry." + }, + "cliTools": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "google-cloud-cli@577.0.0-r1", + "aws-cli@2.36.15-r1", + "azure-cli@2.89.0-r1", + "doctl@1.166.0-r1", + "github-cli@2.97.0-r1", + "gitlab-cli@1.111.0-r1", + "kubectl@1.36.3-r1", + "helm@4.2.3-r1", + "kustomize@5.8.1-r1", + "argocd@3.4.6-r1", + "terraform@1.15.8-r1", + "pulumi@3.255.0-r1", + "supabase-cli@2.111.0-r1", + "firebase-cli@15.25.1-r1", + "flyctl@0.4.78-r1", + "railway-cli@5.30.4-r1", + "stripe-cli@1.45.0-r1", + "duckdb@1.5.5-r1", + "rclone@1.75.0-r1", + "restic@0.19.1-r1", + "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", + "mongosh@2.9.2-r1", + "sops@3.13.3-r1", + "age@1.3.1-r1" + ] + }, + "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates." + }, + "systemPackages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Debian packages installed into the sandbox, one per entry." + }, + "buildStatus": { + "anyOf": [ + { + "type": "string", + "enum": ["pending", "building", "ready", "failed"] + }, + { + "type": "null" + } + ], + "description": "Image build state. `null` when the deployment installs dependencies at run time and has nothing to build." + }, + "errorCode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Classified build failure code, or `null`." + }, + "errorMessage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Human-readable build failure summary, or `null`." + }, + "errorDetail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Tail of the installer log for a failed build, or `null`." + }, + "builtAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the current image finished building, or `null`." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the sandbox was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the sandbox was last updated." + } + }, + "required": [ + "id", + "name", + "language", + "dependencies", + "cliTools", + "systemPackages", + "buildStatus", + "errorCode", + "errorMessage", + "errorDetail", + "builtAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Sandbox", + "description": "A workspace sandbox: a reusable dependency set that Function blocks execute against." + }, + "ListSandboxesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Sandbox" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List sandboxes response", + "description": "Sandboxes defined in the workspace.", + "examples": [ + { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "ready", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": "2026-06-20T14:05:40.000Z", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreateSandboxResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Sandbox" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create sandbox response", + "description": "The created sandbox. `buildStatus` is `pending` while an image builds and `null` where nothing is built.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "pending", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": null, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateSandboxRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the sandbox." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Display name, unique within the workspace; 1 to 64 characters." + }, + "language": { + "type": "string", + "enum": ["javascript", "python"], + "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." + }, + "dependencies": { + "default": [], + "description": "Package specifiers installed into the sandbox, one per entry.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "cliTools": { + "default": [], + "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates.", + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "enum": [ + "google-cloud-cli@577.0.0-r1", + "aws-cli@2.36.15-r1", + "azure-cli@2.89.0-r1", + "doctl@1.166.0-r1", + "github-cli@2.97.0-r1", + "gitlab-cli@1.111.0-r1", + "kubectl@1.36.3-r1", + "helm@4.2.3-r1", + "kustomize@5.8.1-r1", + "argocd@3.4.6-r1", + "terraform@1.15.8-r1", + "pulumi@3.255.0-r1", + "supabase-cli@2.111.0-r1", + "firebase-cli@15.25.1-r1", + "flyctl@0.4.78-r1", + "railway-cli@5.30.4-r1", + "stripe-cli@1.45.0-r1", + "duckdb@1.5.5-r1", + "rclone@1.75.0-r1", + "restic@0.19.1-r1", + "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", + "mongosh@2.9.2-r1", + "sops@3.13.3-r1", + "age@1.3.1-r1" + ] + } + }, + "systemPackages": { + "default": [], + "description": "Debian packages installed into the sandbox, one per entry.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + } + }, + "required": ["workspaceId", "name", "language"], + "additionalProperties": false, + "title": "Create sandbox request", + "description": "Name, language, and dependency set of a new sandbox.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "systemPackages": ["graphviz"] + } + ] + }, + "GetSandboxResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Sandbox" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get sandbox response", + "description": "One sandbox.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "ready", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": "2026-06-20T14:05:40.000Z", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateSandboxResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Sandbox" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update sandbox response", + "description": "The updated sandbox. `buildStatus` is `pending` while an image rebuilds and `null` where nothing is built.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests", "pyarrow"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "pending", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": null, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateSandboxRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the sandbox." + }, + "name": { + "description": "New display name, unique within the workspace; 1 to 64 characters.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "language": { + "description": "Replacement dependency ecosystem. The whole spec is revalidated against it, so a Python dependency list does not survive a switch to JavaScript.", + "type": "string", + "enum": ["javascript", "python"] + }, + "dependencies": { + "description": "Replacement package list; replaces the whole list.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "cliTools": { + "description": "Replacement managed CLI list; replaces the whole list.", + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "enum": [ + "google-cloud-cli@577.0.0-r1", + "aws-cli@2.36.15-r1", + "azure-cli@2.89.0-r1", + "doctl@1.166.0-r1", + "github-cli@2.97.0-r1", + "gitlab-cli@1.111.0-r1", + "kubectl@1.36.3-r1", + "helm@4.2.3-r1", + "kustomize@5.8.1-r1", + "argocd@3.4.6-r1", + "terraform@1.15.8-r1", + "pulumi@3.255.0-r1", + "supabase-cli@2.111.0-r1", + "firebase-cli@15.25.1-r1", + "flyctl@0.4.78-r1", + "railway-cli@5.30.4-r1", + "stripe-cli@1.45.0-r1", + "duckdb@1.5.5-r1", + "rclone@1.75.0-r1", + "restic@0.19.1-r1", + "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", + "mongosh@2.9.2-r1", + "sops@3.13.3-r1", + "age@1.3.1-r1" + ] + } + }, + "systemPackages": { + "description": "Replacement Debian package list; replaces the whole list.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update sandbox request", + "description": "Sandbox fields to change; at least one editable field is required.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "dependencies": ["pandas==2.2.2", "requests", "pyarrow"] + } + ] + }, + "V2SandboxDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted sandbox." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the sandbox was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete sandbox data", + "description": "Sandbox deletion acknowledgement." + }, + "DeleteSandboxResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SandboxDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete sandbox response", + "description": "Acknowledgement that the sandbox was deleted.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } + ] + }, "V2Credential": { "type": "object", "properties": { diff --git a/apps/sim/app/api/v2/sandboxes/[sandboxId]/route.test.ts b/apps/sim/app/api/v2/sandboxes/[sandboxId]/route.test.ts new file mode 100644 index 00000000000..a4a91bda757 --- /dev/null +++ b/apps/sim/app/api/v2/sandboxes/[sandboxId]/route.test.ts @@ -0,0 +1,255 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + get: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + enforceUserRateLimit: vi.fn(), + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), +})) +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + class SandboxDependencyError extends OrchestrationError { + constructor(readonly issues: { line: number; value: string; reason: string }[]) { + super('validation', issues[0]?.reason ?? 'Invalid dependency list') + } + } + class SandboxSystemPackageError extends OrchestrationError { + constructor(readonly issues: { line: number; value: string; reason: string }[]) { + super('validation', issues[0]?.reason ?? 'Invalid system package list') + } + } + return { + SANDBOX_MUTATION_LIMIT: { maxTokens: 20, refillRate: 10, refillIntervalMs: 60_000 }, + SandboxDependencyError, + SandboxSystemPackageError, + } +}) +vi.mock('@/lib/sandboxes/application/use-cases', () => ({ + getWorkspaceSandboxUseCase: { operation: { id: 'sandboxes.read' }, execute: mocks.get }, + updateWorkspaceSandboxUseCase: { operation: { id: 'sandboxes.update' }, execute: mocks.update }, + deleteWorkspaceSandboxUseCase: { operation: { id: 'sandboxes.delete' }, execute: mocks.remove }, +})) + +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, + WorkspaceApiKeyAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DELETE, GET, PATCH } from '@/app/api/v2/sandboxes/[sandboxId]/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rateLimitSubjectIds: ['user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const RATE_LIMIT_OK = { + allowed: true, + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T00:00:00Z'), + retryAfterMs: 0, +} +const sandbox = { + id: 'sandbox-1', + name: 'data-tools', + language: 'python', + dependencies: ['pandas'], + cliTools: [], + systemPackages: [], + buildStatus: 'failed', + errorCode: 'install_failed', + errorMessage: 'pip could not resolve pandas==99', + errorDetail: 'ERROR: No matching distribution found for pandas==99', + builtAt: null, + createdAt: '2026-08-04T11:00:00.000Z', + updatedAt: '2026-08-04T12:00:00.000Z', +} +const context = { params: Promise.resolve({ sandboxId: sandbox.id }) } + +/** + * The read and delete verbs scope themselves with `?workspaceId=`; the write + * verb carries `workspaceId` in its body, and sending the query copy on a write + * is a 400 rather than a silently dropped key. + */ +function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown, query?: string) { + const search = query ?? (method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}`) + return new NextRequest(`http://localhost:3000/api/v2/sandboxes/${sandbox.id}${search}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) +} + +describe('/api/v2/sandboxes/[sandboxId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.get.mockResolvedValue({ sandbox }) + mocks.update.mockResolvedValue({ sandbox }) + mocks.remove.mockResolvedValue({ sandbox }) + }) + + it('gets a sandbox, build failure included, through its read operation', async () => { + const response = await GET(request('GET'), context) + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual(sandbox) + expect(mocks.get).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, sandboxId: sandbox.id }, + request: expect.anything(), + }) + }) + + it('requires the workspace scope on a read', async () => { + const response = await GET(request('GET', undefined, ''), context) + + expect(response.status).toBe(400) + expect(mocks.get).not.toHaveBeenCalled() + }) + + it('conceals a sandbox the caller has no reach into as missing', async () => { + mocks.get.mockRejectedValue(new NoWorkspaceAccessError()) + + const response = await GET(request('GET'), context) + + expect(response.status).toBe(404) + expect((await response.json()).error).toEqual({ + code: 'NOT_FOUND', + message: 'Sandbox not found', + }) + }) + + it('answers a missing workspace as a missing sandbox, not as a missing workspace', async () => { + mocks.get.mockRejectedValue(new OrchestrationError('not_found', 'Workspace not found')) + + const response = await GET(request('GET'), context) + + expect(response.status).toBe(404) + expect((await response.json()).error).toEqual({ + code: 'NOT_FOUND', + message: 'Sandbox not found', + }) + }) + + it('keeps an in-workspace role refusal a 403 with its remedy', async () => { + mocks.update.mockRejectedValue(new InsufficientWorkspacePermissionsError()) + + const response = await PATCH( + request('PATCH', { workspaceId: WORKSPACE_ID, name: 'renamed' }), + context + ) + + expect(response.status).toBe(403) + expect((await response.json()).error.details).toEqual({ code: 'INSUFFICIENT_WORKSPACE_ROLE' }) + }) + + it('tells a workspace key to use a personal key on a write', async () => { + mocks.remove.mockRejectedValue(new WorkspaceApiKeyAuthorizationError()) + + const response = await DELETE(request('DELETE'), context) + + expect(response.status).toBe(403) + expect((await response.json()).error.details).toEqual({ + code: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED', + }) + }) + + it('updates with the sandbox id from the path and the v2 source', async () => { + const response = await PATCH( + request('PATCH', { workspaceId: WORKSPACE_ID, dependencies: ['pandas', 'numpy'] }), + context + ) + + expect(response.status).toBe(200) + expect(mocks.update).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + sandboxId: sandbox.id, + dependencies: ['pandas', 'numpy'], + source: 'api', + }, + request: expect.anything(), + }) + }) + + it('rejects an update that changes nothing before application execution', async () => { + const response = await PATCH(request('PATCH', { workspaceId: WORKSPACE_ID }), context) + + expect(response.status).toBe(400) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('projects a name collision as a conflict', async () => { + mocks.update.mockRejectedValue( + new OrchestrationError('conflict', 'A sandbox named "other" already exists in this workspace') + ) + + const response = await PATCH( + request('PATCH', { workspaceId: WORKSPACE_ID, name: 'other' }), + context + ) + + expect(response.status).toBe(409) + expect((await response.json()).error.code).toBe('CONFLICT') + }) + + it('deletes and acknowledges with the identifier', async () => { + const response = await DELETE(request('DELETE'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: sandbox.id, deleted: true } }) + expect(mocks.remove).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, sandboxId: sandbox.id, source: 'api' }, + request: expect.anything(), + }) + }) +}) diff --git a/apps/sim/app/api/v2/sandboxes/[sandboxId]/route.ts b/apps/sim/app/api/v2/sandboxes/[sandboxId]/route.ts new file mode 100644 index 00000000000..49299a3a6c3 --- /dev/null +++ b/apps/sim/app/api/v2/sandboxes/[sandboxId]/route.ts @@ -0,0 +1,63 @@ +import { + v2DeleteSandboxContract, + v2GetSandboxContract, + v2UpdateSandboxContract, +} from '@/lib/api/contracts/v2/sandboxes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { sandboxOperations } from '@/lib/sandboxes/application/operations' +import { + deleteWorkspaceSandboxUseCase, + getWorkspaceSandboxUseCase, + updateWorkspaceSandboxUseCase, +} from '@/lib/sandboxes/application/use-cases' +import { sandboxResourceErrorPolicy } from '@/app/api/v2/sandboxes/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/sandboxes/[sandboxId] — Get one sandbox. */ +export const GET = defineV2JsonRoute({ + contract: v2GetSandboxContract, + operation: sandboxOperations.read, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: sandboxResourceErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + sandboxId: params.sandboxId, + }), + useCase: getWorkspaceSandboxUseCase, + present: ({ sandbox }) => ({ data: sandbox }), +}) + +/** PATCH /api/v2/sandboxes/[sandboxId] — Update a sandbox and rebuild its image. */ +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateSandboxContract, + operation: sandboxOperations.update, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: sandboxResourceErrorPolicy, + mapInput: ({ params, body }) => ({ + ...body, + sandboxId: params.sandboxId, + source: 'api' as const, + }), + useCase: updateWorkspaceSandboxUseCase, + present: ({ sandbox }) => ({ data: sandbox }), +}) + +/** DELETE /api/v2/sandboxes/[sandboxId] — Delete a sandbox. */ +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteSandboxContract, + operation: sandboxOperations.delete, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: sandboxResourceErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + sandboxId: params.sandboxId, + source: 'api' as const, + }), + useCase: deleteWorkspaceSandboxUseCase, + present: ({ sandbox }) => ({ data: { id: sandbox.id, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/sandboxes/route.test.ts b/apps/sim/app/api/v2/sandboxes/route.test.ts new file mode 100644 index 00000000000..cf9878026d7 --- /dev/null +++ b/apps/sim/app/api/v2/sandboxes/route.test.ts @@ -0,0 +1,316 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + list: vi.fn(), + create: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + enforceUserRateLimit: vi.fn(), + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), +})) +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + class SandboxDependencyError extends OrchestrationError { + constructor(readonly issues: { line: number; value: string; reason: string }[]) { + super('validation', issues[0]?.reason ?? 'Invalid dependency list') + } + } + class SandboxSystemPackageError extends OrchestrationError { + constructor(readonly issues: { line: number; value: string; reason: string }[]) { + super('validation', issues[0]?.reason ?? 'Invalid system package list') + } + } + return { + SANDBOX_MUTATION_LIMIT: { maxTokens: 20, refillRate: 10, refillIntervalMs: 60_000 }, + SandboxDependencyError, + SandboxSystemPackageError, + } +}) +vi.mock('@/lib/sandboxes/application/use-cases', () => ({ + listWorkspaceSandboxesUseCase: { operation: { id: 'sandboxes.list' }, execute: mocks.list }, + createWorkspaceSandboxUseCase: { operation: { id: 'sandboxes.create' }, execute: mocks.create }, +})) + +import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' +import { ForbiddenOperationError } from '@/lib/core/application' +import { SandboxDependencyError } from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { SandboxBuildBudgetExceededError } from '@/lib/sandboxes/application/build-budget' +import { GET, POST } from '@/app/api/v2/sandboxes/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rateLimitSubjectIds: ['user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const RATE_LIMIT_OK = { + allowed: true, + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T00:00:00Z'), + retryAfterMs: 0, +} +const sandbox = { + id: 'sandbox-1', + name: 'data-tools', + language: 'python', + dependencies: ['pandas'], + cliTools: [], + systemPackages: ['graphviz'], + buildStatus: 'ready', + errorCode: null, + errorMessage: null, + errorDetail: null, + builtAt: '2026-08-04T12:00:00.000Z', + createdAt: '2026-08-04T11:00:00.000Z', + updatedAt: '2026-08-04T12:00:00.000Z', +} +const listResult = { + sandboxes: [sandbox], + nextCursorKeys: null, + strategy: 'prebuilt', + entitled: true, + sortBy: 'name', + sortOrder: 'asc', +} + +function request(method: 'GET' | 'POST', url: string, body?: unknown) { + return new NextRequest(`http://localhost:3000${url}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) +} + +describe('/api/v2/sandboxes', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.list.mockResolvedValue(listResult) + mocks.create.mockResolvedValue({ sandbox }) + }) + + it('lists sandboxes through the authorized application use case', async () => { + const response = await GET(request('GET', `/api/v2/sandboxes?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: [sandbox], nextCursor: null }) + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + search: undefined, + sortBy: 'name', + sortOrder: 'asc', + limit: V2_DEFAULT_PAGE_SIZE, + cursorKeys: undefined, + }, + request: expect.anything(), + }) + expect(mocks.operationRate).toHaveBeenCalledWith( + 'v2:sandboxes.list:user:user-1', + expect.objectContaining({ maxTokens: 100 }) + ) + }) + + it('refuses a cursor minted under a different filter', async () => { + mocks.list.mockResolvedValue({ ...listResult, nextCursorKeys: ['data-tools', 'sandbox-1'] }) + + const minted = await GET( + request('GET', `/api/v2/sandboxes?workspaceId=${WORKSPACE_ID}&search=data`) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const replayed = await GET( + request( + 'GET', + `/api/v2/sandboxes?workspaceId=${WORKSPACE_ID}&search=tools&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mocks.list.mockResolvedValue({ ...listResult, nextCursorKeys: ['data-tools', 'sandbox-1'] }) + + const minted = await GET( + request('GET', `/api/v2/sandboxes?workspaceId=${WORKSPACE_ID}&search=data`) + ) + const { nextCursor } = await minted.json() + + mocks.list.mockClear() + const resumed = await GET( + request( + 'GET', + `/api/v2/sandboxes?workspaceId=${WORKSPACE_ID}&search=data&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: expect.objectContaining({ + search: 'data', + cursorKeys: ['data-tools', 'sandbox-1'], + }), + request: expect.anything(), + }) + }) + + it('rejects an unimplemented sort field before application execution', async () => { + const response = await GET( + request('GET', `/api/v2/sandboxes?workspaceId=${WORKSPACE_ID}&sortBy=buildStatus`) + ) + + expect(response.status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('creates a sandbox with the v2 source, defaulted lists, and a 201', async () => { + const response = await POST( + request('POST', '/api/v2/sandboxes', { + workspaceId: WORKSPACE_ID, + name: 'data-tools', + language: 'python', + dependencies: ['pandas'], + }) + ) + + expect(response.status).toBe(201) + expect((await response.json()).data.id).toBe('sandbox-1') + expect(mocks.create).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + name: 'data-tools', + language: 'python', + dependencies: ['pandas'], + cliTools: [], + systemPackages: [], + source: 'api', + }, + request: expect.anything(), + }) + }) + + it('authenticates before validating a malformed create body', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await POST(request('POST', '/api/v2/sandboxes', {})) + + expect(response.status).toBe(401) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('names the plan as the remedy for a workspace below the Max tier', async () => { + mocks.create.mockRejectedValue( + new ForbiddenOperationError( + 'WORKSPACE_PLAN_CAPABILITY_REQUIRED', + 'Sim sandboxes require an active Max or Enterprise plan.' + ) + ) + + const response = await POST( + request('POST', '/api/v2/sandboxes', { + workspaceId: WORKSPACE_ID, + name: 'data-tools', + language: 'python', + }) + ) + + expect(response.status).toBe(403) + expect((await response.json()).error).toMatchObject({ + code: 'FORBIDDEN', + details: { code: 'WORKSPACE_PLAN_CAPABILITY_REQUIRED' }, + }) + }) + + it('addresses a refused dependency entry to its field and row', async () => { + const issue = { line: 2, value: 'not a package!', reason: 'invalid package name' } + mocks.create.mockRejectedValue(new SandboxDependencyError([issue])) + + const response = await POST( + request('POST', '/api/v2/sandboxes', { + workspaceId: WORKSPACE_ID, + name: 'data-tools', + language: 'python', + dependencies: ['pandas', 'not a package!'], + }) + ) + + expect(response.status).toBe(400) + expect((await response.json()).error).toEqual({ + code: 'BAD_REQUEST', + message: 'invalid package name', + details: { issueField: 'dependencies', issues: [issue] }, + }) + }) + + it('answers a spent build budget with 429 and a Retry-After the caller can honor', async () => { + mocks.create.mockRejectedValue( + new SandboxBuildBudgetExceededError(new Date(Date.now() + 30_000), 30_000) + ) + + const response = await POST( + request('POST', '/api/v2/sandboxes', { + workspaceId: WORKSPACE_ID, + name: 'data-tools', + language: 'python', + }) + ) + + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('30') + expect((await response.json()).error).toMatchObject({ + code: 'RATE_LIMITED', + details: { retryAfter: expect.any(String) }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/sandboxes/route.ts b/apps/sim/app/api/v2/sandboxes/route.ts new file mode 100644 index 00000000000..887d6f404a9 --- /dev/null +++ b/apps/sim/app/api/v2/sandboxes/route.ts @@ -0,0 +1,65 @@ +import { v2CreateSandboxContract, v2ListSandboxesContract } from '@/lib/api/contracts/v2/sandboxes' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { sandboxOperations } from '@/lib/sandboxes/application/operations' +import { + createWorkspaceSandboxUseCase, + listWorkspaceSandboxesUseCase, +} from '@/lib/sandboxes/application/use-cases' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' +import { sandboxCollectionErrorPolicy } from '@/app/api/v2/sandboxes/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Every param that changes which sandboxes, in which order, this list returns. */ +function sandboxCursorFilters(query: { workspaceId: string; search?: string }) { + return cursorScopeKey(cursorRoute(v2ListSandboxesContract), { + workspaceId: query.workspaceId, + search: query.search, + }) +} + +/** GET /api/v2/sandboxes — List sandboxes in a workspace. */ +export const GET = defineV2JsonRoute({ + contract: v2ListSandboxesContract, + operation: sandboxOperations.list, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: sandboxCollectionErrorPolicy, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + sandboxCursorFilters(query) + ), + }), + useCase: listWorkspaceSandboxesUseCase, + present: ({ sandboxes, nextCursorKeys }, { query }) => ({ + data: sandboxes, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + sandboxCursorFilters(query) + ), + }), +}) + +/** POST /api/v2/sandboxes — Create a sandbox and schedule its build. */ +export const POST = defineV2JsonRoute({ + contract: v2CreateSandboxContract, + operation: sandboxOperations.create, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: sandboxCollectionErrorPolicy, + mapInput: ({ body }) => ({ ...body, source: 'api' as const }), + useCase: createWorkspaceSandboxUseCase, + present: ({ sandbox }) => ({ data: sandbox }), +}) diff --git a/apps/sim/app/api/v2/sandboxes/utils.ts b/apps/sim/app/api/v2/sandboxes/utils.ts new file mode 100644 index 00000000000..d0fa5b485cf --- /dev/null +++ b/apps/sim/app/api/v2/sandboxes/utils.ts @@ -0,0 +1,57 @@ +import { createV2ResourceConcealmentPolicy, type V2ErrorPolicy } from '@/lib/api/server/routes' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { + SandboxDependencyError, + SandboxSystemPackageError, +} from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { SandboxBuildBudgetExceededError } from '@/lib/sandboxes/application/build-budget' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +export const SANDBOX_NOT_FOUND_MESSAGE = 'Sandbox not found' + +/** + * The failures the shared projection would flatten. A refused dependency or + * system-package line keeps its per-line `issues` under `error.details`, and a + * spent build budget answers `429` with `Retry-After` — through `v2Error` + * rather than `v2RateLimitError`, whose `X-RateLimit-*` headers describe the + * API-key bucket, which is not what refused this request. + */ +export function renderSandboxError(error: unknown) { + if (error instanceof SandboxBuildBudgetExceededError) { + return v2Error('RATE_LIMITED', 'Sandbox build budget exceeded for this workspace', { + headers: { 'Retry-After': String(error.retryAfterSeconds) }, + details: { retryAfter: error.resetAt.toISOString() }, + }) + } + const classified = asOrchestrationError(error) + if (classified instanceof SandboxSystemPackageError) { + return v2Error('BAD_REQUEST', classified.message, { + details: { issueField: 'systemPackages', issues: classified.issues }, + }) + } + if (classified instanceof SandboxDependencyError) { + return v2Error('BAD_REQUEST', classified.message, { + details: { issueField: 'dependencies', issues: classified.issues }, + }) + } + return v2CaughtOrchestrationError(error) +} + +/** + * The item routes answer every absence as a missing sandbox. A missing or + * archived workspace must not read differently from a workspace the caller has + * no reach into, or the message becomes an oracle for which workspace ids exist. + */ +function renderSandboxResourceError(error: unknown) { + if (asOrchestrationError(error)?.code === 'not_found') { + return v2Error('NOT_FOUND', SANDBOX_NOT_FOUND_MESSAGE) + } + return renderSandboxError(error) +} + +export const sandboxCollectionErrorPolicy: V2ErrorPolicy = { render: renderSandboxError } + +export const sandboxResourceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: SANDBOX_NOT_FOUND_MESSAGE, + render: renderSandboxResourceError, +}) diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.test.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.test.ts new file mode 100644 index 00000000000..d7bf305f556 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + defineRoute: vi.fn((definition) => definition), + update: vi.fn(), + remove: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes', () => ({ + defineInternalJsonRoute: mocks.defineRoute, + internalRateLimits: { none: vi.fn(({ reason }) => ({ kind: 'none', reason })) }, + internalSessionAuth: { kind: 'session-auth' }, +})) +vi.mock('@/app/api/workspaces/[id]/sandboxes/error-policy', () => ({ + internalSandboxErrorPolicy: { kind: 'sandbox-policy' }, + internalSandboxResourceErrorPolicy: { kind: 'sandbox-resource-policy' }, +})) +vi.mock('@/lib/sandboxes/application/use-cases', () => ({ + updateWorkspaceSandboxUseCase: { operation: { id: 'sandboxes.update' }, execute: mocks.update }, + deleteWorkspaceSandboxUseCase: { operation: { id: 'sandboxes.delete' }, execute: mocks.remove }, +})) + +import { DELETE, PATCH } from '@/app/api/workspaces/[id]/sandboxes/[sandboxId]/route' + +const params = { id: 'workspace-1', sandboxId: 'sandbox-1' } + +describe('/api/workspaces/[id]/sandboxes/[sandboxId] application adapters', () => { + it('updates through the concealing policy with both ids taken from the path', () => { + expect(PATCH).toMatchObject({ + contract: { method: 'PATCH', path: '/api/workspaces/[id]/sandboxes/[sandboxId]' }, + auth: { kind: 'session-auth' }, + operation: { id: 'sandboxes.update' }, + useCase: { operation: { id: 'sandboxes.update' } }, + rateLimit: { kind: 'none' }, + errorPolicy: { kind: 'sandbox-resource-policy' }, + }) + expect( + Reflect.get(PATCH, 'mapInput')({ params, body: { dependencies: ['pandas', 'numpy'] } }) + ).toEqual({ + workspaceId: 'workspace-1', + sandboxId: 'sandbox-1', + dependencies: ['pandas', 'numpy'], + source: 'settings', + }) + expect(Reflect.get(PATCH, 'present')({ sandbox: { id: 'sandbox-1' } })).toEqual({ + sandbox: { id: 'sandbox-1' }, + }) + }) + + it('deletes through the concealing policy and keeps the legacy acknowledgement', () => { + expect(DELETE).toMatchObject({ + contract: { method: 'DELETE' }, + auth: { kind: 'session-auth' }, + operation: { id: 'sandboxes.delete' }, + useCase: { operation: { id: 'sandboxes.delete' } }, + errorPolicy: { kind: 'sandbox-resource-policy' }, + }) + expect(Reflect.get(DELETE, 'mapInput')({ params })).toEqual({ + workspaceId: 'workspace-1', + sandboxId: 'sandbox-1', + source: 'settings', + }) + expect(Reflect.get(DELETE, 'present')({ sandbox: { id: 'sandbox-1' } })).toEqual({ + success: true, + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts index 82a7d66a789..2a52871a629 100644 --- a/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts @@ -1,57 +1,62 @@ import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { deleteSandboxContract, updateSandboxContract } from '@/lib/api/contracts/sandboxes' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - deleteWorkspaceSandbox, - updateWorkspaceSandbox, -} from '@/lib/execution/remote-sandbox/workspace-sandboxes' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { sandboxOperations } from '@/lib/sandboxes/application/operations' import { - authorizeSandboxMutation, - sandboxMutationErrorResponse, -} from '@/app/api/workspaces/[id]/sandboxes/authorize' + deleteWorkspaceSandboxUseCase, + updateWorkspaceSandboxUseCase, +} from '@/lib/sandboxes/application/use-cases' +import { internalSandboxResourceErrorPolicy } from '@/app/api/workspaces/[id]/sandboxes/error-policy' const logger = createLogger('WorkspaceSandboxAPI') -type SandboxContext = { params: Promise<{ id: string; sandboxId: string }> } - -export const PATCH = withRouteHandler(async (request: NextRequest, context: SandboxContext) => { - const { id: workspaceId, sandboxId } = await context.params - - const authorized = await authorizeSandboxMutation(workspaceId) - if (!authorized.ok) return authorized.response - - const parsed = await parseRequest(updateSandboxContract, request, context) - if (!parsed.success) return parsed.response - try { - const sandbox = await updateWorkspaceSandbox(workspaceId, sandboxId, parsed.data.body) - logger.info('Updated workspace sandbox', { workspaceId, sandboxId }) - return NextResponse.json({ sandbox }) - } catch (error) { - const response = sandboxMutationErrorResponse(error) - if (response) return response - throw error - } +const rateLimit = internalRateLimits.none({ + reason: 'Build admission is the per-workspace budget the use case enforces', }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: SandboxContext) => { - const { id: workspaceId, sandboxId } = await context.params - - const authorized = await authorizeSandboxMutation(workspaceId) - if (!authorized.ok) return authorized.response - - const parsed = await parseRequest(deleteSandboxContract, request, context) - if (!parsed.success) return parsed.response - - try { - await deleteWorkspaceSandbox(workspaceId, sandboxId) - } catch (error) { - const response = sandboxMutationErrorResponse(error) - if (response) return response - throw error - } +export const PATCH = defineInternalJsonRoute({ + contract: updateSandboxContract, + auth: internalSessionAuth, + operation: sandboxOperations.update, + rateLimit, + errorPolicy: internalSandboxResourceErrorPolicy, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + sandboxId: params.sandboxId, + ...body, + source: 'settings' as const, + }), + useCase: updateWorkspaceSandboxUseCase, + onSuccess: ({ input }) => { + logger.info('Updated workspace sandbox', { + workspaceId: input.workspaceId, + sandboxId: input.sandboxId, + }) + }, + present: ({ sandbox }) => ({ sandbox }), +}) - logger.info('Deleted workspace sandbox', { workspaceId, sandboxId }) - return NextResponse.json({ success: true }) +export const DELETE = defineInternalJsonRoute({ + contract: deleteSandboxContract, + auth: internalSessionAuth, + operation: sandboxOperations.delete, + rateLimit, + errorPolicy: internalSandboxResourceErrorPolicy, + mapInput: ({ params }) => ({ + workspaceId: params.id, + sandboxId: params.sandboxId, + source: 'settings' as const, + }), + useCase: deleteWorkspaceSandboxUseCase, + onSuccess: ({ input }) => { + logger.info('Deleted workspace sandbox', { + workspaceId: input.workspaceId, + sandboxId: input.sandboxId, + }) + }, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.test.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.test.ts deleted file mode 100644 index 3dade9948d8..00000000000 --- a/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import type { SandboxValidationError } from '@/lib/api/contracts/sandboxes' -import { - SandboxDependencyError, - SandboxSystemPackageError, -} from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { sandboxMutationErrorResponse } from '@/app/api/workspaces/[id]/sandboxes/authorize' - -describe('sandboxMutationErrorResponse', () => { - it('addresses dependency issues to the dependency editor', async () => { - const response = sandboxMutationErrorResponse( - new SandboxDependencyError([ - { line: 1, value: 'requests; rm -rf /', reason: 'Invalid dependency' }, - ]) - ) - - expect(response).not.toBeNull() - const body = (await response!.json()) as SandboxValidationError - expect(response!.status).toBe(400) - expect(body.issueField).toBe('dependencies') - expect(body.issues).toEqual([expect.objectContaining({ line: 1, value: 'requests; rm -rf /' })]) - }) - - it('addresses system package issues to the system package editor', async () => { - const response = sandboxMutationErrorResponse( - new SandboxSystemPackageError([ - { - line: 1, - value: '--allow-unauthenticated', - reason: 'Invalid system package', - }, - ]) - ) - - expect(response).not.toBeNull() - const body = (await response!.json()) as SandboxValidationError - expect(response!.status).toBe(400) - expect(body.issueField).toBe('systemPackages') - expect(body.issues).toEqual([ - expect.objectContaining({ line: 1, value: '--allow-unauthenticated' }), - ]) - }) -}) diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts deleted file mode 100644 index a95e960cf65..00000000000 --- a/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import type { SandboxValidationError } from '@/lib/api/contracts/sandboxes' -import { getSession } from '@/lib/auth' -import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' -import { enforceWorkspaceRateLimit } from '@/lib/core/rate-limiter/route-helpers' -import { - MAX_PLAN_REQUIRED, - SANDBOX_ADMIN_REQUIRED, - SANDBOX_MUTATION_LIMIT, - SandboxDependencyError, - SandboxSystemPackageError, - WorkspaceSandboxNameConflictError, - WorkspaceSandboxNotFoundError, -} from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -export interface SandboxMutationActor { - userId: string - name?: string | null - email?: string | null -} - -/** - * The 409 both write paths return for a duplicate name. Shared so the pre-check - * and the unique-index catch cannot describe the same conflict differently. - */ -export function nameConflictResponse(name: string): NextResponse { - return NextResponse.json( - { error: `A sandbox named "${name}" already exists in this workspace` }, - { status: 409 } - ) -} - -/** Maps shared sandbox-service errors onto the HTTP contract. */ -export function sandboxMutationErrorResponse(error: unknown): NextResponse | null { - if (error instanceof WorkspaceSandboxNameConflictError) { - return nameConflictResponse(error.sandboxName) - } - if (error instanceof WorkspaceSandboxNotFoundError) { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error instanceof SandboxSystemPackageError) { - const body = { - error: error.message, - issueField: 'systemPackages', - issues: error.issues, - } satisfies SandboxValidationError - return NextResponse.json(body, { status: 400 }) - } - if (error instanceof SandboxDependencyError) { - const body = { - error: error.message, - issueField: 'dependencies', - issues: error.issues, - } satisfies SandboxValidationError - return NextResponse.json(body, { status: 400 }) - } - return null -} - -/** - * Authenticates, authorizes, entitles, and rate-limits a sandbox mutation — in - * that order, and always before any untrusted input is parsed. - * - * Shared by both route files so the create path and the edit/delete path cannot - * drift into different checks. - */ -export async function authorizeSandboxMutation( - workspaceId: string -): Promise<{ ok: true; actor: SandboxMutationActor } | { ok: false; response: NextResponse }> { - const session = await getSession() - if (!session?.user?.id) { - return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - } - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin') { - return { - ok: false, - response: NextResponse.json({ error: SANDBOX_ADMIN_REQUIRED }, { status: 403 }), - } - } - if (!(await hasWorkspaceSandboxAccess(workspaceId))) { - return { - ok: false, - response: NextResponse.json({ error: MAX_PLAN_REQUIRED }, { status: 403 }), - } - } - const limited = await enforceWorkspaceRateLimit( - 'sandbox-mutations', - workspaceId, - SANDBOX_MUTATION_LIMIT - ) - if (limited) return { ok: false, response: limited } - - return { - ok: true, - actor: { userId: session.user.id, name: session.user.name, email: session.user.email }, - } -} - -/** Reads a workspace sandbox list; any member may look, only admins may write. */ -export async function authorizeSandboxRead( - _request: NextRequest, - workspaceId: string -): Promise<{ ok: true; userId: string } | { ok: false; response: NextResponse }> { - const session = await getSession() - if (!session?.user?.id) { - return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - } - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (!permission) { - return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - } - return { ok: true, userId: session.user.id } -} diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/error-policy.test.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/error-policy.test.ts new file mode 100644 index 00000000000..4e338ff3b2a --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/error-policy.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = vi.fn() + checkRateLimitDirectOrThrow = vi.fn() + }, + enforceUserRateLimit: vi.fn(), + getRateLimit: vi.fn(), +})) +vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + class SandboxDependencyError extends OrchestrationError { + constructor(readonly issues: { line: number; value: string; reason: string }[]) { + super('validation', issues[0]?.reason ?? 'Invalid dependency list') + } + } + class SandboxSystemPackageError extends OrchestrationError { + constructor(readonly issues: { line: number; value: string; reason: string }[]) { + super('validation', issues[0]?.reason ?? 'Invalid system package list') + } + } + return { + SANDBOX_MUTATION_LIMIT: { maxTokens: 20, refillRate: 10, refillIntervalMs: 60_000 }, + SandboxDependencyError, + SandboxSystemPackageError, + } +}) + +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + SandboxDependencyError, + SandboxSystemPackageError, +} from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { SandboxBuildBudgetExceededError } from '@/lib/sandboxes/application/build-budget' +import { + internalSandboxErrorPolicy, + internalSandboxResourceErrorPolicy, +} from '@/app/api/workspaces/[id]/sandboxes/error-policy' + +const ISSUE = { line: 2, value: 'not a package!', reason: 'invalid package name' } + +describe('internal sandbox error policy', () => { + /** + * The editor marks the submitted row from `issueField` + `issues`, so the + * body is the one the route-local authorizer used to build, field for field. + */ + it('addresses a refused dependency line to its field and row', () => { + const descriptor = internalSandboxErrorPolicy.project(new SandboxDependencyError([ISSUE])) + + expect(descriptor).toEqual({ + status: 400, + body: { error: 'invalid package name', issueField: 'dependencies', issues: [ISSUE] }, + headers: undefined, + }) + }) + + it('addresses a refused system package the same way', () => { + const descriptor = internalSandboxErrorPolicy.project(new SandboxSystemPackageError([ISSUE])) + + expect(descriptor).toMatchObject({ + status: 400, + body: { issueField: 'systemPackages', issues: [ISSUE] }, + }) + }) + + it('finds the refusal behind a wrapping query error', () => { + const wrapped = new Error('Failed query', { cause: new SandboxDependencyError([ISSUE]) }) + + expect(internalSandboxErrorPolicy.project(wrapped)).toMatchObject({ status: 400 }) + }) + + it('keeps the legacy 429 body and headers for a spent build budget', () => { + const resetAt = new Date(Date.now() + 30_000) + const descriptor = internalSandboxErrorPolicy.project( + new SandboxBuildBudgetExceededError(resetAt, 30_000) + ) + + expect(descriptor).toEqual({ + status: 429, + body: { error: 'Rate limit exceeded', retryAfter: resetAt.getTime() }, + headers: { 'Retry-After': '30', 'X-RateLimit-Reset': resetAt.toISOString() }, + }) + }) + + it('projects a name collision as a conflict', () => { + const descriptor = internalSandboxErrorPolicy.project( + new OrchestrationError('conflict', 'A sandbox named "x" already exists in this workspace') + ) + + expect(descriptor).toMatchObject({ + status: 409, + body: { error: 'A sandbox named "x" already exists in this workspace' }, + }) + }) + + it('leaves an unclassified failure to the generic 500', () => { + expect(internalSandboxErrorPolicy.project(new Error('pg down'))).toBeNull() + }) + + /** + * A caller with no reach into the workspace learns nothing from an item + * route, while a member whose role is too low keeps the actionable 403. + */ + it('conceals a cross-tenant refusal on item routes as a missing sandbox', () => { + expect(internalSandboxResourceErrorPolicy.project(new NoWorkspaceAccessError())).toMatchObject({ + status: 404, + body: { error: 'Sandbox not found' }, + }) + expect( + internalSandboxResourceErrorPolicy.project(new InsufficientWorkspacePermissionsError()) + ).toMatchObject({ status: 403 }) + }) + + /** + * A missing workspace and a workspace the caller cannot reach are both a + * missing sandbox on item routes; a distinct message would tell a probe which + * workspace ids exist. The collection policy keeps the specific message. + */ + it('answers a missing workspace on item routes as a missing sandbox', () => { + const missingWorkspace = new OrchestrationError('not_found', 'Workspace not found') + + expect(internalSandboxResourceErrorPolicy.project(missingWorkspace)).toMatchObject({ + status: 404, + body: { error: 'Sandbox not found' }, + }) + expect(internalSandboxErrorPolicy.project(missingWorkspace)).toMatchObject({ + status: 404, + body: { error: 'Workspace not found' }, + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/error-policy.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/error-policy.ts new file mode 100644 index 00000000000..37296714413 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/error-policy.ts @@ -0,0 +1,70 @@ +import type { SandboxValidationError } from '@/lib/api/contracts/sandboxes' +import { + createInternalResourceConcealmentPolicy, + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { + SandboxDependencyError, + SandboxSystemPackageError, +} from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { SandboxBuildBudgetExceededError } from '@/lib/sandboxes/application/build-budget' + +export const SANDBOX_NOT_FOUND_MESSAGE = 'Sandbox not found' + +/** + * The bodies the settings editor reads. `issueField` and `issues` address a + * refused dependency or system-package line back to the row the user typed it + * on, and the budget refusal keeps the legacy `retryAfter` body and headers. + * Everything else is the shared orchestration projection. + */ +export const internalSandboxErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => { + if (error instanceof SandboxBuildBudgetExceededError) { + return internalErrorResponse( + 429, + { error: error.message, retryAfter: error.resetAt.getTime() }, + { + 'Retry-After': String(error.retryAfterSeconds), + 'X-RateLimit-Reset': error.resetAt.toISOString(), + } + ) + } + const classified = asOrchestrationError(error) + if (classified instanceof SandboxSystemPackageError) { + const body = { + error: classified.message, + issueField: 'systemPackages', + issues: classified.issues, + } satisfies SandboxValidationError + return internalErrorResponse(400, body) + } + if (classified instanceof SandboxDependencyError) { + const body = { + error: classified.message, + issueField: 'dependencies', + issues: classified.issues, + } satisfies SandboxValidationError + return internalErrorResponse(400, body) + } + return null + } +) + +/** + * The item routes answer every absence, and every concealed refusal, as a + * missing sandbox. A missing or archived workspace must not read differently + * from a workspace the caller has no reach into, or the message becomes an + * oracle for which workspace ids exist. + */ +export const internalSandboxResourceErrorPolicy = createInternalResourceConcealmentPolicy({ + base: extendInternalErrorPolicy(internalSandboxErrorPolicy, (error) => + asOrchestrationError(error)?.code === 'not_found' + ? internalErrorResponse(404, { error: SANDBOX_NOT_FOUND_MESSAGE }) + : null + ), + notFoundMessage: SANDBOX_NOT_FOUND_MESSAGE, +}) diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/route.test.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/route.test.ts new file mode 100644 index 00000000000..41562709882 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/route.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + defineRoute: vi.fn((definition) => definition), + list: vi.fn(), + create: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes', () => ({ + defineInternalJsonRoute: mocks.defineRoute, + internalRateLimits: { none: vi.fn(({ reason }) => ({ kind: 'none', reason })) }, + internalSessionAuth: { kind: 'session-auth' }, +})) +vi.mock('@/app/api/workspaces/[id]/sandboxes/error-policy', () => ({ + internalSandboxErrorPolicy: { kind: 'sandbox-policy' }, + internalSandboxResourceErrorPolicy: { kind: 'sandbox-resource-policy' }, +})) +vi.mock('@/lib/sandboxes/application/use-cases', () => ({ + listWorkspaceSandboxesUseCase: { operation: { id: 'sandboxes.list' }, execute: mocks.list }, + createWorkspaceSandboxUseCase: { operation: { id: 'sandboxes.create' }, execute: mocks.create }, +})) + +import { GET, POST } from '@/app/api/workspaces/[id]/sandboxes/route' + +const sandbox = { + id: 'sandbox-1', + name: 'data-tools', + language: 'python', + dependencies: ['pandas'], + cliTools: [], + systemPackages: [], + buildStatus: 'ready', + errorCode: null, + errorMessage: null, + errorDetail: null, + builtAt: '2026-08-04T12:00:00.000Z', + createdAt: '2026-08-04T11:00:00.000Z', + updatedAt: '2026-08-04T12:00:00.000Z', +} + +describe('/api/workspaces/[id]/sandboxes application adapters', () => { + it('binds the list to the read operation, session auth, and the shared error policy', () => { + expect(GET).toMatchObject({ + contract: { method: 'GET', path: '/api/workspaces/[id]/sandboxes' }, + auth: { kind: 'session-auth' }, + operation: { id: 'sandboxes.list' }, + useCase: { operation: { id: 'sandboxes.list' } }, + rateLimit: { kind: 'none' }, + errorPolicy: { kind: 'sandbox-policy' }, + }) + }) + + it('lists the whole set in name order and presents exactly the legacy body', () => { + expect(Reflect.get(GET, 'mapInput')({ params: { id: 'workspace-1' } })).toEqual({ + workspaceId: 'workspace-1', + sortBy: 'name', + sortOrder: 'asc', + }) + expect( + Reflect.get( + GET, + 'present' + )({ + sandboxes: [sandbox], + nextCursorKeys: null, + strategy: 'prebuilt', + entitled: false, + sortBy: 'name', + sortOrder: 'asc', + }) + ).toEqual({ sandboxes: [sandbox], strategy: 'prebuilt', entitled: false }) + }) + + it('creates from the settings surface with the workspace taken from the path', () => { + expect(POST).toMatchObject({ + contract: { method: 'POST' }, + auth: { kind: 'session-auth' }, + operation: { id: 'sandboxes.create' }, + useCase: { operation: { id: 'sandboxes.create' } }, + rateLimit: { kind: 'none' }, + }) + const body = { + name: 'data-tools', + language: 'python', + dependencies: ['pandas'], + cliTools: [], + systemPackages: [], + } + expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workspace-1' }, body })).toEqual({ + workspaceId: 'workspace-1', + ...body, + source: 'settings', + }) + expect(Reflect.get(POST, 'present')({ sandbox })).toEqual({ sandbox }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts index 8e9c3fbcd02..294c2bca5ac 100644 --- a/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts @@ -1,72 +1,62 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { createSandboxContract } from '@/lib/api/contracts/sandboxes' -import { parseRequest } from '@/lib/api/server' -import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSandboxContract, listSandboxesContract } from '@/lib/api/contracts/sandboxes' import { - createWorkspaceSandbox, - currentSandboxStrategy, - listWorkspaceSandboxes, -} from '@/lib/execution/remote-sandbox/workspace-sandboxes' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { sandboxOperations } from '@/lib/sandboxes/application/operations' import { - authorizeSandboxMutation, - authorizeSandboxRead, - sandboxMutationErrorResponse, -} from '@/app/api/workspaces/[id]/sandboxes/authorize' + createWorkspaceSandboxUseCase, + listWorkspaceSandboxesUseCase, +} from '@/lib/sandboxes/application/use-cases' +import { internalSandboxErrorPolicy } from '@/app/api/workspaces/[id]/sandboxes/error-policy' const logger = createLogger('WorkspaceSandboxesAPI') -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const workspaceId = (await context.params).id - - const viewer = await authorizeSandboxRead(request, workspaceId) - if (!viewer.ok) return viewer.response - - // The list itself is not plan-gated: a workspace that downgraded must still - // see (and keep executing) what it already built. `entitled` drives whether - // the editor renders or an upgrade prompt does. - const [sandboxes, entitled] = await Promise.all([ - listWorkspaceSandboxes(workspaceId), - hasWorkspaceSandboxAccess(workspaceId), - ]) - - return NextResponse.json({ - sandboxes, - strategy: currentSandboxStrategy(), - entitled, +/** + * The list is not plan-gated: a workspace that dropped below the Max tier must + * still see what it built, and `entitled` drives whether the editor renders or + * an upgrade prompt does. Name order is what the settings page has always + * shown. + */ +export const GET = defineInternalJsonRoute({ + contract: listSandboxesContract, + auth: internalSessionAuth, + operation: sandboxOperations.list, + rateLimit: internalRateLimits.none({ + reason: 'A small per-workspace read the legacy route never limited', + }), + errorPolicy: internalSandboxErrorPolicy, + mapInput: ({ params }) => ({ + workspaceId: params.id, + sortBy: 'name' as const, + sortOrder: 'asc' as const, + }), + useCase: listWorkspaceSandboxesUseCase, + present: ({ sandboxes, strategy, entitled }) => ({ sandboxes, strategy, entitled }), +}) + +export const POST = defineInternalJsonRoute({ + contract: createSandboxContract, + auth: internalSessionAuth, + operation: sandboxOperations.create, + rateLimit: internalRateLimits.none({ + reason: 'Build admission is the per-workspace budget the use case enforces', + }), + errorPolicy: internalSandboxErrorPolicy, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + ...body, + source: 'settings' as const, + }), + useCase: createWorkspaceSandboxUseCase, + onSuccess: ({ input, result }) => { + logger.info('Created workspace sandbox', { + workspaceId: input.workspaceId, + sandboxId: result.sandbox.id, + language: result.sandbox.language, }) - } -) - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const workspaceId = (await context.params).id - - const authorized = await authorizeSandboxMutation(workspaceId) - if (!authorized.ok) return authorized.response - - const parsed = await parseRequest(createSandboxContract, request, context) - if (!parsed.success) return parsed.response - try { - const sandbox = await createWorkspaceSandbox( - workspaceId, - authorized.actor.userId, - parsed.data.body - ) - logger.info('Created workspace sandbox', { - workspaceId, - sandboxId: sandbox.id, - language: sandbox.language, - }) - return NextResponse.json({ sandbox }) - } catch (error) { - const response = sandboxMutationErrorResponse(error) - if (response) return response - logger.error('Failed to insert sandbox', { workspaceId, error: getErrorMessage(error) }) - throw error - } - } -) + }, + present: ({ sandbox }) => ({ sandbox }), +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 6e2be871da3..999e7544375 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -165,6 +165,9 @@ export function SettingsSidebar({ if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) { return false } + if (item.id === 'sandboxes' && permissionConfig.hideSandboxesTab) { + return false + } if (item.id === 'forks' && !(forkingAvailable && canAdminWorkspace)) { return false } diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 7bbb4e0aa2c..e39d4905aab 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -476,6 +476,7 @@ describe('settings navigation boundaries', () => { hideInboxTab: true, disableMcpTools: true, disableCustomTools: true, + hideSandboxesTab: true, }, entitlements: { byok: true, @@ -490,7 +491,6 @@ describe('settings navigation boundaries', () => { expect(items.map(({ id }) => id)).toEqual([ 'teammates', 'byok', - 'sandboxes', 'credential-groups', 'workflow-mcp-servers', 'recently-deleted', diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index bfeecdb187b..8bbc4d031a4 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -983,6 +983,7 @@ export interface WorkspacePermissionConfig { hideInboxTab?: boolean disableMcpTools?: boolean disableCustomTools?: boolean + hideSandboxesTab?: boolean } const WORKSPACE_PERMISSION_CONFIG_KEYS: Partial< @@ -993,6 +994,7 @@ const WORKSPACE_PERMISSION_CONFIG_KEYS: Partial< inbox: 'hideInboxTab', mcp: 'disableMcpTools', 'custom-tools': 'disableCustomTools', + sandboxes: 'hideSandboxesTab', } export function workspaceSectionUsesPermissionConfig(section: WorkspaceSettingsSection): boolean { diff --git a/apps/sim/lib/api/contracts/sandboxes.ts b/apps/sim/lib/api/contracts/sandboxes.ts index 04c9725d09f..4f9560d7a94 100644 --- a/apps/sim/lib/api/contracts/sandboxes.ts +++ b/apps/sim/lib/api/contracts/sandboxes.ts @@ -12,7 +12,7 @@ export const sandboxCliToolSchema = z.enum(SANDBOX_CLI_TOOL_IDS) export type SandboxCliToolId = z.output -const sandboxCliToolsSchema = z +export const sandboxCliToolsSchema = z .array(sandboxCliToolSchema) .max(MAX_SANDBOX_CLI_TOOLS, `A sandbox can install at most ${MAX_SANDBOX_CLI_TOOLS} CLI tools`) .refine((cliTools) => new Set(cliTools).size === cliTools.length, { @@ -39,15 +39,15 @@ export const sandboxStrategySchema = z.enum(['prebuilt', 'runtime']) * would reject a legal list (50 packages plus a trailing newline is 51 entries) * with a generic error carrying no `issues`, leaving the editor nothing to mark. */ -const dependencyListSchema = z +export const dependencyListSchema = z .array(z.string().max(2000, 'a dependency line is unreasonably long')) .max(1000, 'too many lines — paste a shorter dependency list') -const systemPackageListSchema = z +export const systemPackageListSchema = z .array(z.string().max(2000, 'a system package line is unreasonably long')) .max(1000, 'too many lines — paste a shorter system package list') -const sandboxNameSchema = z +export const sandboxNameSchema = z .string() .trim() .min(1, 'Name is required') diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index fdd11f5ea89..d8e491e9710 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -59,6 +59,7 @@ const PAGED_LISTS = [ 'GET /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks', 'GET /api/v2/logs', 'GET /api/v2/mcp-servers', + 'GET /api/v2/sandboxes', 'GET /api/v2/secrets', 'GET /api/v2/skills', 'GET /api/v2/skills/[skillId]/editors', @@ -218,6 +219,7 @@ const CURSOR_BINDINGS: Record = { 'includeJobRuns', ], 'GET /api/v2/mcp-servers': ['workspaceId', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/sandboxes': ['workspaceId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/secrets': ['workspaceId', 'scope', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/skills': ['workspaceId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/tables': ['workspaceId', 'scope', 'folderPath', 'search', 'sortBy', 'sortOrder'], diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 88db312c508..b5808398a3f 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -46,6 +46,13 @@ import { withErrorExamples, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' +import { + v2CreateSandboxContract, + v2DeleteSandboxContract, + v2GetSandboxContract, + v2ListSandboxesContract, + v2UpdateSandboxContract, +} from '@/lib/api/contracts/v2/sandboxes' import { v2DeleteSecretContract, v2ListSecretsContract, @@ -368,6 +375,34 @@ const CUSTOM_TOOL_EXAMPLE = { updatedAt: '2026-06-20T14:02:11.000Z', } as const +/** + * No managed CLI in the example: the catalog pins exact versions that rotate + * with every upgrade, and an example naming one would break the spec check on + * each bump. + */ +const SANDBOX_EXAMPLE = { + id: 'V1StGXR8Z5jdHi6BmyT', + name: 'data-tools', + language: 'python', + dependencies: ['pandas==2.2.2', 'requests'], + cliTools: [], + systemPackages: ['graphviz'], + buildStatus: 'ready', + errorCode: null, + errorMessage: null, + errorDetail: null, + builtAt: '2026-06-20T14:05:40.000Z', + createdAt: '2026-06-01T09:14:00.000Z', + updatedAt: '2026-06-20T14:02:11.000Z', +} as const + +const SANDBOX_ADMIN_PLAN_NOTE = + 'Requires a workspace admin on a Max or Enterprise plan; a lower plan is refused with `403` and `error.details.code` `WORKSPACE_PLAN_CAPABILITY_REQUIRED`.' + +/** Creates and updates only: deleting builds nothing and is never refused on budget. */ +const SANDBOX_BUILD_BUDGET_NOTE = + 'Creates and updates in a workspace share one write budget, whatever the install strategy, and a burst is refused with `429` and a `Retry-After` header.' + const CREDENTIAL_EXAMPLE = { id: '7c9e6679-7425-40de-944b-e07fc1f90ae7', type: 'service_account', @@ -465,6 +500,7 @@ type ResourceTag = | 'MCP Servers' | 'Skills' | 'Custom Tools' + | 'Sandboxes' | 'Credentials' | 'Secrets' | 'Catalog' @@ -1192,6 +1228,175 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListSandboxesContract, + resourceOperation('Sandboxes', { + operationId: 'listSandboxes', + summary: 'List Sandboxes', + description: + 'List the sandboxes defined in a workspace, with opaque cursor pagination. A sandbox is a reusable dependency set — npm or PyPI packages, pinned managed CLIs, and Debian packages — that Function blocks execute against. Listing is not plan-gated, so a workspace that dropped below the Max tier still sees what it built.', + errors: RESOURCE_ERRORS, + success: { description: 'Sandboxes defined in the workspace.' }, + }), + { + query: documentedSchema( + v2ListSandboxesContract.query, + 'ListSandboxesQuery', + 'List sandboxes query', + 'Workspace, search, sorting, and paging controls for sandboxes.' + ), + response: documentedSchema( + v2ListSandboxesContract.response.schema, + 'ListSandboxesResponse', + 'List sandboxes response', + 'Sandboxes defined in the workspace.', + [{ data: [SANDBOX_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2CreateSandboxContract, + resourceOperation('Sandboxes', { + operationId: 'createSandbox', + summary: 'Create Sandbox', + description: `Create a sandbox. The name must be unique within the workspace. Where the deployment prebuilds dependency images, the build is scheduled and reported through \`buildStatus\`; a deployment that installs at run time, or a sandbox with nothing to install, has no build and reports \`buildStatus: null\`. A dependency or system-package entry the builder cannot accept is a \`400\` whose \`error.details\` names the field and the offending entries. ${SANDBOX_ADMIN_PLAN_NOTE} ${SANDBOX_BUILD_BUDGET_NOTE} ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: + 'The sandbox was created; a build is scheduled where the deployment prebuilds images.', + }, + }), + { + query: v2CreateSandboxContract.query, + body: documentedSchema( + v2CreateSandboxContract.body, + 'CreateSandboxRequest', + 'Create sandbox request', + 'Name, language, and dependency set of a new sandbox.', + [ + { + workspaceId: WORKSPACE_ID, + name: SANDBOX_EXAMPLE.name, + language: SANDBOX_EXAMPLE.language, + dependencies: SANDBOX_EXAMPLE.dependencies, + systemPackages: SANDBOX_EXAMPLE.systemPackages, + }, + ] + ), + response: documentedSchema( + v2CreateSandboxContract.response.schema, + 'CreateSandboxResponse', + 'Create sandbox response', + 'The created sandbox. `buildStatus` is `pending` while an image builds and `null` where nothing is built.', + [{ data: { ...SANDBOX_EXAMPLE, buildStatus: 'pending', builtAt: null } }] + ), + } + ), + defineOpenApiRoute( + v2GetSandboxContract, + resourceOperation('Sandboxes', { + operationId: 'getSandbox', + summary: 'Get Sandbox', + description: + 'Fetch one sandbox by identifier, scoped to its workspace, including its current build state and any build failure.', + errors: RESOURCE_ERRORS, + success: { description: 'The sandbox.' }, + }), + { + params: documentedSchema( + v2GetSandboxContract.params, + 'GetSandboxParams', + 'Get sandbox path parameters', + 'Sandbox selected for retrieval.' + ), + query: documentedSchema( + v2GetSandboxContract.query, + 'GetSandboxQuery', + 'Get sandbox query', + 'Workspace scope for the sandbox.' + ), + response: documentedSchema( + v2GetSandboxContract.response.schema, + 'GetSandboxResponse', + 'Get sandbox response', + 'One sandbox.', + [{ data: SANDBOX_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2UpdateSandboxContract, + resourceOperation('Sandboxes', { + operationId: 'updateSandbox', + summary: 'Update Sandbox', + description: `Update the supplied sandbox fields. Omitted fields retain their stored values; a supplied list replaces the whole list; names must remain unique within the workspace. Where the deployment prebuilds dependency images, a changed spec is rebuilt and re-sending an unchanged spec after a failed build retries it; a deployment that installs at run time, or a spec with nothing to install, has no build and reports \`buildStatus: null\`. ${SANDBOX_ADMIN_PLAN_NOTE} ${SANDBOX_BUILD_BUDGET_NOTE} ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The updated sandbox.' }, + }), + { + query: v2UpdateSandboxContract.query, + params: documentedSchema( + v2UpdateSandboxContract.params, + 'UpdateSandboxParams', + 'Update sandbox path parameters', + 'Sandbox selected for update.' + ), + body: documentedSchema( + v2UpdateSandboxContract.body, + 'UpdateSandboxRequest', + 'Update sandbox request', + 'Sandbox fields to change; at least one editable field is required.', + [{ workspaceId: WORKSPACE_ID, dependencies: ['pandas==2.2.2', 'requests', 'pyarrow'] }] + ), + response: documentedSchema( + v2UpdateSandboxContract.response.schema, + 'UpdateSandboxResponse', + 'Update sandbox response', + 'The updated sandbox. `buildStatus` is `pending` while an image rebuilds and `null` where nothing is built.', + [ + { + data: { + ...SANDBOX_EXAMPLE, + dependencies: ['pandas==2.2.2', 'requests', 'pyarrow'], + buildStatus: 'pending', + builtAt: null, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2DeleteSandboxContract, + resourceOperation('Sandboxes', { + operationId: 'deleteSandbox', + summary: 'Delete Sandbox', + description: `Delete a sandbox. Function blocks that still select it fail closed at run time until they are re-pointed. Where the deployment prebuilds dependency images, the sandbox's image is released once nothing else shares it; a runtime-install deployment, or a spec with nothing to install, had no image and nothing is released. ${SANDBOX_ADMIN_PLAN_NOTE} ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The sandbox was deleted.' }, + }), + { + params: documentedSchema( + v2DeleteSandboxContract.params, + 'DeleteSandboxParams', + 'Delete sandbox path parameters', + 'Sandbox selected for deletion.' + ), + query: documentedSchema( + v2DeleteSandboxContract.query, + 'DeleteSandboxQuery', + 'Delete sandbox query', + 'Workspace scope for the sandbox.' + ), + response: documentedSchema( + v2DeleteSandboxContract.response.schema, + 'DeleteSandboxResponse', + 'Delete sandbox response', + 'Acknowledgement that the sandbox was deleted.', + [{ data: { id: SANDBOX_EXAMPLE.id, deleted: true } }] + ), + } + ), defineOpenApiRoute( v2ListCredentialsContract, resourceOperation('Credentials', { @@ -1894,7 +2099,7 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ info: { title: 'Sim API v2 — Workspace Resources', description: - 'Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, credentials, write-only secrets, and the block, tool, and connector-type catalogs.', + 'Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, sandboxes, credentials, write-only secrets, and the block, tool, and connector-type catalogs.', version: '2.0.0', contact: { name: 'Sim Support', @@ -1928,6 +2133,11 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ name: 'Custom Tools', description: 'Create and manage code-backed tools that agents can call.', }, + { + name: 'Sandboxes', + description: + 'Create and manage the reusable dependency sets that Function blocks execute against.', + }, { name: 'Credentials', description: @@ -1946,8 +2156,13 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ securitySchemes: V2_API_KEY_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, + /** + * Most `409`s in this document are name collisions, but MCP tool discovery + * raises one for a stored OAuth grant that must be reauthorized, so the shared + * example stays generic and each operation's description names its own cause. + */ errorResponses: withErrorExamples({ - Conflict: { message: 'API key name already exists' }, + Conflict: { message: 'The request conflicts with the current state of the resource' }, }), routes, }) diff --git a/apps/sim/lib/api/contracts/v2/sandboxes.ts b/apps/sim/lib/api/contracts/v2/sandboxes.ts new file mode 100644 index 00000000000..01391daa87f --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/sandboxes.ts @@ -0,0 +1,229 @@ +import { z } from 'zod' +import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + dependencyListSchema, + sandboxBuildStatusSchema, + sandboxCliToolSchema, + sandboxCliToolsSchema, + sandboxLanguageSchema, + sandboxNameSchema, + systemPackageListSchema, +} from '@/lib/api/contracts/sandboxes' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, + v2SearchSchema, + v2SortFields, + v2TimestampSchema, +} from '@/lib/api/contracts/v2/shared' + +/** + * v2 sandbox contracts. + * + * The internal `/api/workspaces/[id]/sandboxes` surface is session-only and + * carries `strategy` and `entitled` beside the list for the settings editor. + * v2 is API-key-only and names the workspace on every request; a write needs a + * Max or Enterprise plan and answers `403` otherwise, so the editor's flags + * have no place here. The name, language, dependency, CLI, and system-package + * validators are the internal contract's own, so both surfaces accept exactly + * the same spec. + */ + +const LANGUAGE_DESCRIPTION = + 'Dependency ecosystem: `javascript` installs from npm, `python` from PyPI.' +const DEPENDENCIES_DESCRIPTION = 'Package specifiers installed into the sandbox, one per entry.' +const CLI_TOOLS_DESCRIPTION = + 'Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates.' +const SYSTEM_PACKAGES_DESCRIPTION = 'Debian packages installed into the sandbox, one per entry.' + +export const v2SandboxSchema = z + .object({ + id: z.string().describe('Unique sandbox identifier.'), + name: z.string().describe('Display name, unique within the workspace.'), + language: sandboxLanguageSchema.describe(LANGUAGE_DESCRIPTION), + dependencies: z.array(z.string()).describe(DEPENDENCIES_DESCRIPTION), + cliTools: z.array(sandboxCliToolSchema).describe(CLI_TOOLS_DESCRIPTION), + systemPackages: z.array(z.string()).describe(SYSTEM_PACKAGES_DESCRIPTION), + buildStatus: sandboxBuildStatusSchema + .nullable() + .describe( + 'Image build state. `null` when the deployment installs dependencies at run time and has nothing to build.' + ), + errorCode: z.string().nullable().describe('Classified build failure code, or `null`.'), + errorMessage: z + .string() + .nullable() + .describe('Human-readable build failure summary, or `null`.'), + errorDetail: z + .string() + .nullable() + .describe('Tail of the installer log for a failed build, or `null`.'), + builtAt: v2TimestampSchema + .nullable() + .describe('ISO 8601 timestamp when the current image finished building, or `null`.'), + createdAt: v2TimestampSchema.describe('ISO 8601 timestamp when the sandbox was created.'), + updatedAt: v2TimestampSchema.describe('ISO 8601 timestamp when the sandbox was last updated.'), + }) + .meta({ + id: 'V2Sandbox', + title: 'Sandbox', + description: + 'A workspace sandbox: a reusable dependency set that Function blocks execute against.', + }) +export type V2Sandbox = z.output + +export const v2SandboxDeleteDataSchema = z + .object({ + id: z.string().describe('Identifier of the deleted sandbox.'), + deleted: z.literal(true).describe('Whether the sandbox was deleted.'), + }) + .meta({ + id: 'V2SandboxDeleteData', + title: 'Delete sandbox data', + description: 'Sandbox deletion acknowledgement.', + }) +export type V2SandboxDeleteData = z.output + +export const v2SandboxParamsSchema = z.object({ + sandboxId: nonEmptyIdSchema.describe('Unique sandbox identifier.'), +}) +export type V2SandboxParams = z.output + +export const v2SandboxWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the sandbox.'), + }) + .strict() +export type V2SandboxWorkspaceQuery = z.output + +export const v2SandboxSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2SandboxSortBy = (typeof v2SandboxSortFields)[number] + +export const v2ListSandboxesQuerySchema = v2SandboxWorkspaceQuerySchema + .extend({ + search: v2SearchSchema.describe('Case-insensitive substring match against the sandbox name.'), + ...v2SortFields(v2SandboxSortFields, { sortBy: 'name', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum sandboxes to return per page.' }), + }) + .strict() + +export type V2ListSandboxesQuery = z.output + +export const v2CreateSandboxBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace in which to create the sandbox.'), + name: sandboxNameSchema.describe( + 'Display name, unique within the workspace; 1 to 64 characters.' + ), + language: sandboxLanguageSchema.describe(LANGUAGE_DESCRIPTION), + dependencies: dependencyListSchema.default([]).describe(DEPENDENCIES_DESCRIPTION), + cliTools: sandboxCliToolsSchema.default([]).describe(CLI_TOOLS_DESCRIPTION), + systemPackages: systemPackageListSchema.default([]).describe(SYSTEM_PACKAGES_DESCRIPTION), + }) + .strict() +export type V2CreateSandboxBody = z.input + +/** Update body. Omitted fields keep their stored values; a supplied list replaces the whole list. */ +export const v2UpdateSandboxBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the sandbox.'), + name: sandboxNameSchema + .optional() + .describe('New display name, unique within the workspace; 1 to 64 characters.'), + language: sandboxLanguageSchema + .optional() + .describe( + 'Replacement dependency ecosystem. The whole spec is revalidated against it, so a Python dependency list does not survive a switch to JavaScript.' + ), + dependencies: dependencyListSchema + .optional() + .describe('Replacement package list; replaces the whole list.'), + cliTools: sandboxCliToolsSchema + .optional() + .describe('Replacement managed CLI list; replaces the whole list.'), + systemPackages: systemPackageListSchema + .optional() + .describe('Replacement Debian package list; replaces the whole list.'), + }) + .strict() + .superRefine((body, ctx) => { + if ( + body.name === undefined && + body.language === undefined && + body.dependencies === undefined && + body.cliTools === undefined && + body.systemPackages === undefined + ) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: + 'At least one of name, language, dependencies, cliTools, or systemPackages is required', + }) + } + }) +export type V2UpdateSandboxBody = z.input + +/** + * Sandbox list, keyset-paginated over the active sort. The set is small per + * workspace, but each row carries its dependency lists and installer log tail, + * so it pages like every other v2 resource list. + */ +export const v2ListSandboxesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/sandboxes', + query: v2ListSandboxesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2SandboxSchema), + }, +}) + +export const v2CreateSandboxContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/sandboxes', + query: noInputSchema, + body: v2CreateSandboxBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SandboxSchema), + status: 201, + }, +}) + +export const v2GetSandboxContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/sandboxes/[sandboxId]', + params: v2SandboxParamsSchema, + query: v2SandboxWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SandboxSchema), + }, +}) + +export const v2UpdateSandboxContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/sandboxes/[sandboxId]', + query: noInputSchema, + params: v2SandboxParamsSchema, + body: v2UpdateSandboxBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SandboxSchema), + }, +}) + +export const v2DeleteSandboxContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/sandboxes/[sandboxId]', + params: v2SandboxParamsSchema, + query: v2SandboxWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SandboxDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index 7edb6d3ccf2..b61fbbf72ff 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -69,6 +69,7 @@ import { hasPaidSubscription, hasWorkspaceLiveSyncAccess, hasWorkspaceSandboxAccess, + hasWorkspaceSandboxRetentionAccess, isOrganizationOnEnterprisePlan, isWorkspaceOnEnterprisePlan, resolveOrganizationPlan, @@ -425,6 +426,89 @@ describe('hasWorkspaceSandboxAccess', () => { }) }) +describe('hasWorkspaceSandboxRetentionAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ + isBillingEnabled: true, + isHosted: true, + isSandboxDeploymentEntitled: false, + isSandboxesEnabled: true, + }) + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-host', + billedAccountUserId: 'workspace-owner', + organizationId: null, + }) + }) + + /** + * The point of the retention intent: a card that failed last night must not + * turn every deployed Function block into an outage this morning. + */ + it('keeps a past_due Max payer running without consulting usability', async () => { + mockGetHighestPriorityPersonalSubscription.mockResolvedValue({ + referenceId: 'workspace-owner', + plan: 'pro_25000', + status: 'past_due', + }) + mockGetPlanTierCredits.mockReturnValue(25000) + + await expect(hasWorkspaceSandboxRetentionAccess('workspace-host')).resolves.toBe(true) + expect(mockHasUsableSubscriptionAccess).not.toHaveBeenCalled() + expect(mockGetEffectiveBillingStatus).not.toHaveBeenCalled() + }) + + it('fails a payer that dropped below the Max tier', async () => { + mockGetHighestPriorityPersonalSubscription.mockResolvedValue({ + referenceId: 'workspace-owner', + plan: 'pro_6000', + status: 'active', + }) + mockGetPlanTierCredits.mockReturnValue(6000) + + await expect(hasWorkspaceSandboxRetentionAccess('workspace-host')).resolves.toBe(false) + }) + + it('fails a payer with no entitled subscription left', async () => { + mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null) + + await expect(hasWorkspaceSandboxRetentionAccess('workspace-host')).resolves.toBe(false) + }) + + it('grants the deployment override without resolving a payer', async () => { + setEnvFlags({ isSandboxDeploymentEntitled: true }) + + await expect(hasWorkspaceSandboxRetentionAccess('workspace-host')).resolves.toBe(true) + expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() + }) + + it('fails closed before resolving a payer when the remote feature is unavailable', async () => { + setEnvFlags({ isSandboxesEnabled: false }) + + await expect(hasWorkspaceSandboxRetentionAccess('workspace-host')).resolves.toBe(false) + expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() + }) + + /** + * A one-shot gate may read an outage as a lapse; a cached gate must not, or + * it would hold every Function block shut for a whole TTL. The caller asks, + * and the ask is forwarded to the reader so the failure is not swallowed + * one level down. + */ + it('reports a read failure as a lapse unless asked to throw', async () => { + mockGetHighestPriorityPersonalSubscription.mockRejectedValue(new Error('billing down')) + + await expect(hasWorkspaceSandboxRetentionAccess('workspace-host')).resolves.toBe(false) + await expect( + hasWorkspaceSandboxRetentionAccess('workspace-host', { onError: 'throw' }) + ).rejects.toThrow('billing down') + expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenLastCalledWith('workspace-owner', { + onError: 'throw', + }) + }) +}) + describe('resolveOrganizationPlan', () => { const ORGANIZATION_ID = 'org-1' diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 228c212ae59..2eeddee3fda 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -670,6 +670,15 @@ interface WorkspaceTierAccessOptions { * so a missing workspace never reads as "safe to destroy". */ onMissingWorkspace?: boolean + /** + * What a subscription-read failure resolves to. By default the reads soft-fail + * to "no subscription", which a one-shot gate correctly reads as a denial. A + * caller that *caches* the answer must pass `'throw'`: a swallowed failure is + * indistinguishable from a real lapse, and caching it would hold the gate + * shut for a whole TTL over a momentary outage. Honored on the `retention` + * reads, which are the only ones a cached caller uses. + */ + onError?: 'return-null' | 'throw' } /** @@ -699,7 +708,8 @@ async function hasWorkspaceTierAccess( isTierEntitled: (plan: string) => boolean, options: WorkspaceTierAccessOptions = {} ): Promise { - const { intent = 'active-use', onMissingWorkspace = false } = options + const { intent = 'active-use', onMissingWorkspace = false, onError } = options + const readOptions = onError === 'throw' ? ({ onError: 'throw' } as const) : {} const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) @@ -708,11 +718,14 @@ async function hasWorkspaceTierAccess( if (intent === 'retention') { if (ws.organizationId) { const { getOrganizationSubscription } = await import('@/lib/billing/core/billing') - const orgSub = await getOrganizationSubscription(ws.organizationId) + const orgSub = await getOrganizationSubscription(ws.organizationId, readOptions) return !!orgSub && isTierEntitled(orgSub.plan) } - const billedSub = await getHighestPriorityPersonalSubscription(ws.billedAccountUserId) + const billedSub = await getHighestPriorityPersonalSubscription( + ws.billedAccountUserId, + readOptions + ) return !!billedSub && isTierEntitled(billedSub.plan) } @@ -831,10 +844,11 @@ export async function hasWorkspaceLiveSyncAccess(workspaceId: string): Promise { try { @@ -848,6 +862,43 @@ export async function hasWorkspaceSandboxAccess(workspaceId: string): Promise { + try { + if (!isSandboxesEnabled) return false + if (isSandboxDeploymentEntitled) return true + if (!isBillingEnabled) return false + return await hasWorkspaceTierAccess(workspaceId, isMaxTier, { + intent: 'retention', + onMissingWorkspace: true, + ...(options.onError === 'throw' ? { onError: 'throw' as const } : {}), + }) + } catch (error) { + logger.error('Error checking workspace sandbox retention access', { error, workspaceId }) + if (options.onError === 'throw') throw error + return false + } +} + /** * Send welcome email for Pro and Team plan subscriptions */ diff --git a/apps/sim/lib/copilot/application/execute-sandbox-use-case.test.ts b/apps/sim/lib/copilot/application/execute-sandbox-use-case.test.ts new file mode 100644 index 00000000000..4448b49667f --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-sandbox-use-case.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { executeCopilotSandboxUseCase } from '@/lib/copilot/application/execute-sandbox-use-case' +import { customToolOperations } from '@/lib/custom-tools/application/operations' +import { sandboxOperations } from '@/lib/sandboxes/application/operations' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as const + +describe('executeCopilotSandboxUseCase', () => { + it('normalizes trusted Copilot authority into the sandbox delegation', async () => { + const execute = vi.fn().mockResolvedValue({ sandboxes: [] }) + const useCase = { operation: sandboxOperations.list, execute } + const input = { workspaceId: trustedContext.workspaceId } + + await expect(executeCopilotSandboxUseCase(trustedContext, useCase, input)).resolves.toEqual({ + sandboxes: [], + }) + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: trustedContext.userId, + workspaceId: trustedContext.workspaceId, + delegationId: `copilot-tool:${trustedContext.toolCallId}`, + audience: 'sim:sandboxes', + resourceScope: expect.objectContaining({ + chatId: trustedContext.chatId, + executionId: trustedContext.executionId, + }), + }), + input, + }) + }) + + it('rejects an untrusted Copilot marker before application execution', () => { + const execute = vi.fn() + const useCase = { operation: sandboxOperations.create, execute } + + expect(() => + executeCopilotSandboxUseCase({ ...trustedContext, copilotToolExecution: false }, useCase, { + workspaceId: trustedContext.workspaceId, + name: 'data-tools', + language: 'python', + dependencies: [], + }) + ).toThrow('trusted Copilot execution context') + expect(execute).not.toHaveBeenCalled() + }) + + it("refuses a use case from another domain's registry", () => { + const execute = vi.fn() + const useCase = { operation: customToolOperations.list, execute } + + expect(() => + executeCopilotSandboxUseCase(trustedContext, useCase, { + workspaceId: trustedContext.workspaceId, + }) + ).toThrow('Unregistered Copilot sandbox operation') + expect(execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/application/execute-sandbox-use-case.ts b/apps/sim/lib/copilot/application/execute-sandbox-use-case.ts new file mode 100644 index 00000000000..852adc4c90d --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-sandbox-use-case.ts @@ -0,0 +1,14 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { sandboxDelegationPolicy } from '@/lib/sandboxes/application/authorization' +import { sandboxOperations } from '@/lib/sandboxes/application/operations' + +export const executeCopilotSandboxUseCase = createCopilotApplicationAdapter({ + domain: 'sandbox', + delegation: { + audience: sandboxDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: sandboxOperations, +}) diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index f98111dec88..1b9e432d050 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -41,6 +41,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/output.mdx', 'cli/profiles.mdx', 'cli/reference.mdx', + 'cli/sandboxes.mdx', 'cli/scripting.mdx', 'cli/secrets.mdx', 'cli/skills.mdx', diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 694e5f44f38..d09207d091b 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -112,7 +112,7 @@ vi.mock('@/lib/copilot/tools/secret-mount-materializer.server', () => ({ vi.mock('@/lib/billing/core/subscription', () => ({ hasWorkspaceSandboxAccess: mockHasWorkspaceSandboxAccess, })) -vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', () => ({ +vi.mock('@/lib/execution/remote-sandbox/entitlement', () => ({ MAX_PLAN_REQUIRED: 'Sim sandboxes require an active Max or Enterprise plan.', })) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 31f5cb917dd..fad976b167f 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -17,8 +17,8 @@ import { MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, PRIVATE_SECRET_PROVENANCE_FIELD, } from '@/lib/execution/private-tool-metadata' +import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/entitlement' import type { SandboxFile } from '@/lib/execution/remote-sandbox/types' -import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' import { createSandboxMountBudget, MAX_INLINE_MOUNT_FILE_BYTES, diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts index 393ee1935bb..c5b491043e9 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts @@ -2,28 +2,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ - hasAccess: vi.fn(), - enforceRateLimit: vi.fn(), - create: vi.fn(), - update: vi.fn(), - remove: vi.fn(), - list: vi.fn(), - getPermission: vi.fn(), +const { mocks, useCases } = vi.hoisted(() => ({ + mocks: { + sandbox: vi.fn(), + }, + useCases: { + list: { operation: { id: 'sandboxes.list' } }, + create: { operation: { id: 'sandboxes.create' } }, + update: { operation: { id: 'sandboxes.update' } }, + delete: { operation: { id: 'sandboxes.delete' } }, + }, })) -vi.mock('@/lib/billing/core/subscription', () => ({ - hasWorkspaceSandboxAccess: mocks.hasAccess, +vi.mock('@/lib/copilot/application/execute-sandbox-use-case', () => ({ + executeCopilotSandboxUseCase: mocks.sandbox, })) - -vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ - enforceWorkspaceRateLimit: mocks.enforceRateLimit, +vi.mock('@/lib/sandboxes/application/use-cases', () => ({ + listWorkspaceSandboxesUseCase: useCases.list, + createWorkspaceSandboxUseCase: useCases.create, + updateWorkspaceSandboxUseCase: useCases.update, + deleteWorkspaceSandboxUseCase: useCases.delete, })) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mocks.getPermission, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = vi.fn() + }, })) - vi.mock('@/lib/execution/remote-sandbox/cli-tools', () => ({ SANDBOX_CLI_TOOL_IDS: ['kubectl@1.36.3-r1'], MAX_SANDBOX_CLI_TOOLS: 10, @@ -36,39 +40,39 @@ vi.mock('@/lib/execution/remote-sandbox/cli-tools', () => ({ }, }, })) - -vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', () => { - class SandboxDependencyError extends Error { - issues: unknown[] = [] +vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + class SandboxDependencyError extends OrchestrationError { + constructor(readonly issues: { line: number; value: string; reason: string }[]) { + super('validation', issues[0]?.reason ?? 'Invalid dependency list') + } } - class SandboxSystemPackageError extends Error { - issues: unknown[] = [] + class SandboxSystemPackageError extends OrchestrationError { + constructor(readonly issues: { line: number; value: string; reason: string }[]) { + super('validation', issues[0]?.reason ?? 'Invalid system package list') + } } - class WorkspaceSandboxNameConflictError extends Error {} - class WorkspaceSandboxNotFoundError extends Error {} return { - createWorkspaceSandbox: mocks.create, - updateWorkspaceSandbox: mocks.update, - deleteWorkspaceSandbox: mocks.remove, - listWorkspaceSandboxes: mocks.list, - currentSandboxStrategy: () => 'prebuilt', - MAX_PLAN_REQUIRED: 'Sim sandboxes require an active Max or Enterprise plan.', - SANDBOX_ADMIN_REQUIRED: 'Only workspace admins can manage Sim sandboxes', SANDBOX_MUTATION_LIMIT: { maxTokens: 20, refillRate: 10, refillIntervalMs: 60_000 }, SandboxDependencyError, SandboxSystemPackageError, - WorkspaceSandboxNameConflictError, - WorkspaceSandboxNotFoundError, } }) import type { ExecutionContext } from '@/lib/copilot/request/types' +import { ForbiddenOperationError } from '@/lib/core/application' +import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/entitlement' +import { SandboxDependencyError } from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { SandboxBuildBudgetExceededError } from '@/lib/sandboxes/application/build-budget' import { executeManageSandbox } from './manage-sandbox' const context: ExecutionContext = { userId: 'user-1', workflowId: '', workspaceId: 'workspace-1', + chatId: 'chat-1', + toolCallId: 'call-1', + copilotToolExecution: true, userPermission: 'admin', } @@ -91,43 +95,29 @@ const sandbox = { describe('executeManageSandbox', () => { beforeEach(() => { vi.clearAllMocks() - mocks.hasAccess.mockResolvedValue(true) - mocks.getPermission.mockResolvedValue('admin') - mocks.enforceRateLimit.mockResolvedValue(null) - mocks.list.mockResolvedValue([sandbox]) - mocks.create.mockResolvedValue(sandbox) - }) - - it('fails closed before discovery when the workspace is not entitled', async () => { - mocks.hasAccess.mockResolvedValue(false) - - const result = await executeManageSandbox({ operation: 'list' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('Max or Enterprise') - expect(mocks.getPermission).toHaveBeenCalledWith('user-1', 'workspace', 'workspace-1') - expect(mocks.list).not.toHaveBeenCalled() - }) - - it('rechecks current admin permission in Sim instead of trusting request context', async () => { - mocks.getPermission.mockResolvedValue('write') - - const result = await executeManageSandbox({ operation: 'list' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('workspace admins') - expect(mocks.hasAccess).not.toHaveBeenCalled() - expect(mocks.list).not.toHaveBeenCalled() + mocks.sandbox.mockResolvedValue({ + sandboxes: [sandbox], + nextCursorKeys: null, + strategy: 'prebuilt', + entitled: true, + sortBy: 'name', + sortOrder: 'asc', + }) }) - it('lists through the shared sandbox service and returns the authoritative CLI catalog', async () => { + it('lists through the shared use case and returns the authoritative CLI catalog', async () => { const result = await executeManageSandbox({ operation: 'list' }, context) - expect(mocks.list).toHaveBeenCalledWith('workspace-1') + expect(mocks.sandbox).toHaveBeenCalledWith(context, useCases.list, { + workspaceId: 'workspace-1', + sortBy: 'name', + sortOrder: 'asc', + }) expect(result).toMatchObject({ success: true, output: { strategy: 'prebuilt', + entitled: true, count: 1, sandboxes: [sandbox], availableCliTools: [{ id: 'kubectl@1.36.3-r1' }], @@ -135,7 +125,39 @@ describe('executeManageSandbox', () => { }) }) - it('validates then creates through the shared sandbox service', async () => { + /** + * The list is a read, so a workspace below the Max tier still sees what it + * built; `entitled: false` is how the model learns that writes will be + * refused, instead of a refusal that hid the list. + */ + it('still lists below the Max tier and reports that writes will be refused', async () => { + mocks.sandbox.mockResolvedValue({ + sandboxes: [sandbox], + nextCursorKeys: null, + strategy: 'prebuilt', + entitled: false, + sortBy: 'name', + sortOrder: 'asc', + }) + + const result = await executeManageSandbox({ operation: 'list' }, context) + + expect(result).toMatchObject({ success: true, output: { entitled: false, count: 1 } }) + }) + + it('ignores a model-supplied workspace in favor of the server context', async () => { + await executeManageSandbox({ operation: 'list', workspaceId: 'model-workspace' }, context) + + expect(mocks.sandbox).toHaveBeenCalledWith( + context, + useCases.list, + expect.objectContaining({ workspaceId: 'workspace-1' }) + ) + }) + + it('validates then creates through the shared use case', async () => { + mocks.sandbox.mockResolvedValue({ sandbox }) + const result = await executeManageSandbox( { operation: 'add', @@ -148,18 +170,113 @@ describe('executeManageSandbox', () => { context ) - expect(mocks.enforceRateLimit).toHaveBeenCalledWith( - 'sandbox-mutations', - 'workspace-1', - expect.any(Object) - ) - expect(mocks.create).toHaveBeenCalledWith('workspace-1', 'user-1', { + expect(mocks.sandbox).toHaveBeenCalledWith(context, useCases.create, { + workspaceId: 'workspace-1', name: 'data-tools', language: 'python', dependencies: ['pandas'], cliTools: ['kubectl@1.36.3-r1'], systemPackages: ['graphviz'], + source: 'tool_input', }) expect(result).toMatchObject({ success: true, output: { sandboxId: 'sandbox-1' } }) }) + + it('rejects a malformed add before reaching the use case', async () => { + const result = await executeManageSandbox( + { operation: 'add', name: '', language: 'python' }, + context + ) + + expect(result.success).toBe(false) + expect(mocks.sandbox).not.toHaveBeenCalled() + }) + + it('edits and deletes the sandbox the model named', async () => { + mocks.sandbox.mockResolvedValue({ sandbox }) + + await executeManageSandbox( + { operation: 'edit', sandboxId: 'sandbox-1', dependencies: ['pandas', 'numpy'] }, + context + ) + expect(mocks.sandbox).toHaveBeenCalledWith(context, useCases.update, { + workspaceId: 'workspace-1', + sandboxId: 'sandbox-1', + dependencies: ['pandas', 'numpy'], + source: 'tool_input', + }) + + mocks.sandbox.mockResolvedValue({ sandbox }) + const deleted = await executeManageSandbox( + { operation: 'delete', sandboxId: 'sandbox-1' }, + context + ) + expect(mocks.sandbox).toHaveBeenCalledWith(context, useCases.delete, { + workspaceId: 'workspace-1', + sandboxId: 'sandbox-1', + source: 'tool_input', + }) + expect(deleted).toMatchObject({ success: true, output: { sandboxId: 'sandbox-1' } }) + }) + + it('requires a sandbox id for edit and delete', async () => { + const result = await executeManageSandbox({ operation: 'delete' }, context) + + expect(result).toMatchObject({ success: false, error: expect.stringContaining('sandboxId') }) + expect(mocks.sandbox).not.toHaveBeenCalled() + }) + + it('surfaces the plan refusal the use case raised', async () => { + mocks.sandbox.mockRejectedValue( + new ForbiddenOperationError('WORKSPACE_PLAN_CAPABILITY_REQUIRED', MAX_PLAN_REQUIRED) + ) + + const result = await executeManageSandbox( + { operation: 'add', name: 'data-tools', language: 'python' }, + context + ) + + expect(result).toEqual({ success: false, error: MAX_PLAN_REQUIRED }) + }) + + it('tells the model not to retry a spent build budget', async () => { + mocks.sandbox.mockRejectedValue( + new SandboxBuildBudgetExceededError(new Date(Date.now() + 60_000), 60_000) + ) + + const result = await executeManageSandbox( + { operation: 'add', name: 'data-tools', language: 'python' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('do not retry now') + }) + + it('addresses a refused dependency line back to its row', async () => { + mocks.sandbox.mockRejectedValue( + new SandboxDependencyError([{ line: 2, value: 'not a package!', reason: 'invalid name' }]) + ) + + const result = await executeManageSandbox( + { operation: 'add', name: 'data-tools', language: 'python', dependencies: ['a', 'b'] }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('dependencies line 2') + }) + + it('hides an unclassified failure behind the retry guidance', async () => { + mocks.sandbox.mockRejectedValue(new Error('connection refused')) + + const result = await executeManageSandbox( + { operation: 'delete', sandboxId: 'sandbox-1' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).not.toContain('connection refused') + expect(result.error).toContain('run operation "list"') + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts index 47913ca6103..313f44e8be9 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts @@ -1,25 +1,22 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { toError } from '@sim/utils/errors' import { createSandboxBodySchema, updateSandboxBodySchema } from '@/lib/api/contracts/sandboxes' -import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotSandboxUseCase } from '@/lib/copilot/application/execute-sandbox-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { enforceWorkspaceRateLimit } from '@/lib/core/rate-limiter/route-helpers' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { SANDBOX_CLI_TOOLS } from '@/lib/execution/remote-sandbox/cli-tools' import { - createWorkspaceSandbox, - currentSandboxStrategy, - deleteWorkspaceSandbox, - listWorkspaceSandboxes, - MAX_PLAN_REQUIRED, - SANDBOX_ADMIN_REQUIRED, - SANDBOX_MUTATION_LIMIT, SandboxDependencyError, SandboxSystemPackageError, - updateWorkspaceSandbox, - WorkspaceSandboxNameConflictError, - WorkspaceSandboxNotFoundError, } from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { SandboxBuildBudgetExceededError } from '@/lib/sandboxes/application/build-budget' +import { + createWorkspaceSandboxUseCase, + deleteWorkspaceSandboxUseCase, + listWorkspaceSandboxesUseCase, + updateWorkspaceSandboxUseCase, +} from '@/lib/sandboxes/application/use-cases' const logger = createLogger('CopilotManageSandbox') @@ -43,17 +40,28 @@ function validationMessage(error: SandboxDependencyError | SandboxSystemPackageE return `${error.message}${issues ? ` — ${issues}` : ''}` } -function sandboxErrorMessage(error: unknown): string { - if (error instanceof SandboxDependencyError || error instanceof SandboxSystemPackageError) { - return validationMessage(error) +/** + * Maps expected application failures to something the model can act on. A + * spent build budget must not be retried; a refused line names the row; every + * other classified refusal (plan, role, conflict, not found) carries its own + * message; anything else is the generic retry-with-list guidance, with the + * cause kept in server logs. + */ +function sandboxErrorMessage(error: unknown, operation: ManageSandboxOperation): string { + if (error instanceof SandboxBuildBudgetExceededError) { + return `Rate limit exceeded for sandbox ${operation} in this workspace — do not retry now; continue with other work or tell the user the limit was hit.` } + const classified = asOrchestrationError(error) if ( - error instanceof WorkspaceSandboxNameConflictError || - error instanceof WorkspaceSandboxNotFoundError + classified instanceof SandboxDependencyError || + classified instanceof SandboxSystemPackageError ) { - return error.message + return validationMessage(classified) } - return getErrorMessage(error) + return messageForCopilotApplicationError( + error, + `The ${operation} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.` + ) } /** Executes the Mothership agent's Sim-sandbox management tool. */ @@ -63,6 +71,12 @@ export async function executeManageSandbox( ): Promise { const params = rawParams as ManageSandboxParams const operation = String(params.operation || '').toLowerCase() as ManageSandboxOperation + /** + * Server-set context only. The use case authorizes against the workspace the + * delegated principal carries, so a model-supplied workspace could never win + * here — but it must not be read at all, or a mismatch would surface as a + * confusing refusal rather than never arising. + */ const workspaceId = context.workspaceId if (!workspaceId) return { success: false, error: 'workspaceId is required' } @@ -71,25 +85,20 @@ export async function executeManageSandbox( } try { - // Re-read current authorization in Sim. Mothership's entitlement and the - // permission captured at request start control model visibility only; a - // delayed or resumed mutation must not survive a downgrade or role change. - const permission = await getUserEntityPermissions(context.userId, 'workspace', workspaceId) - if (permission !== 'admin') { - return { success: false, error: SANDBOX_ADMIN_REQUIRED } - } - if (!(await hasWorkspaceSandboxAccess(workspaceId))) { - return { success: false, error: MAX_PLAN_REQUIRED } - } - if (operation === 'list') { - const sandboxes = await listWorkspaceSandboxes(workspaceId) + const { sandboxes, strategy, entitled } = await executeCopilotSandboxUseCase( + context, + listWorkspaceSandboxesUseCase, + { workspaceId, sortBy: 'name', sortOrder: 'asc' } + ) return { success: true, output: { success: true, operation, - strategy: currentSandboxStrategy(), + strategy, + /** False below the Max tier: add, edit, and delete will be refused. */ + entitled, sandboxes, count: sandboxes.length, availableCliTools: Object.values(SANDBOX_CLI_TOOLS), @@ -97,17 +106,6 @@ export async function executeManageSandbox( } } - const limited = await enforceWorkspaceRateLimit( - 'sandbox-mutations', - workspaceId, - SANDBOX_MUTATION_LIMIT - ) - if (limited) - return { - success: false, - error: `Rate limit exceeded for sandbox ${operation} in this workspace — do not retry now; continue with other work or tell the user the limit was hit.`, - } - if (operation === 'add') { const parsed = createSandboxBodySchema.safeParse({ name: params.name, @@ -118,7 +116,11 @@ export async function executeManageSandbox( }) if (!parsed.success) return { success: false, error: parsed.error.issues[0]?.message } - const sandbox = await createWorkspaceSandbox(workspaceId, context.userId, parsed.data) + const { sandbox } = await executeCopilotSandboxUseCase( + context, + createWorkspaceSandboxUseCase, + { workspaceId, ...parsed.data, source: 'tool_input' } + ) return { success: true, output: { @@ -145,7 +147,11 @@ export async function executeManageSandbox( }) if (!parsed.success) return { success: false, error: parsed.error.issues[0]?.message } - const sandbox = await updateWorkspaceSandbox(workspaceId, params.sandboxId, parsed.data) + const { sandbox } = await executeCopilotSandboxUseCase( + context, + updateWorkspaceSandboxUseCase, + { workspaceId, sandboxId: params.sandboxId, ...parsed.data, source: 'tool_input' } + ) return { success: true, output: { @@ -158,7 +164,11 @@ export async function executeManageSandbox( } } - await deleteWorkspaceSandbox(workspaceId, params.sandboxId) + await executeCopilotSandboxUseCase(context, deleteWorkspaceSandboxUseCase, { + workspaceId, + sandboxId: params.sandboxId, + source: 'tool_input', + }) return { success: true, output: { @@ -174,6 +184,6 @@ export async function executeManageSandbox( operation, error: toError(error), }) - return { success: false, error: sandboxErrorMessage(error) } + return { success: false, error: sandboxErrorMessage(error, operation) } } } diff --git a/apps/sim/lib/execution/remote-sandbox/entitlement.test.ts b/apps/sim/lib/execution/remote-sandbox/entitlement.test.ts new file mode 100644 index 00000000000..8a510b25db1 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/entitlement.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRetentionAccess } = vi.hoisted(() => ({ + mockRetentionAccess: vi.fn(), +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceSandboxRetentionAccess: mockRetentionAccess, +})) + +import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight' +import { + hasWorkspaceSandboxRetentionAccessCached, + MAX_PLAN_REQUIRED, + resetSandboxEntitlementCache, +} from '@/lib/execution/remote-sandbox/entitlement' + +const WORKSPACE_ID = 'workspace-1' + +describe('cached sandbox retention entitlement', () => { + beforeEach(() => { + vi.clearAllMocks() + resetSandboxEntitlementCache() + __resetCoalesceLocallyForTests() + mockRetentionAccess.mockResolvedValue(true) + }) + + it('names the plan in the message every surface shares', () => { + expect(MAX_PLAN_REQUIRED).toContain('Max or Enterprise') + }) + + it('serves the execution path from cache instead of re-reading billing', async () => { + await expect(hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID)).resolves.toBe(true) + await expect(hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID)).resolves.toBe(true) + await expect(hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID)).resolves.toBe(true) + + expect(mockRetentionAccess).toHaveBeenCalledTimes(1) + }) + + /** + * The cached value is a boolean, so a truthiness check would read a cached + * `false` as a miss — re-querying billing on every Function block for + * exactly the workspaces the cache exists to protect. + */ + it('serves a cached false without re-reading billing', async () => { + mockRetentionAccess.mockResolvedValue(false) + + await expect(hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID)).resolves.toBe(false) + await expect(hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID)).resolves.toBe(false) + + expect(mockRetentionAccess).toHaveBeenCalledTimes(1) + }) + + it('collapses concurrent resolutions into one read', async () => { + let release: (value: boolean) => void = () => {} + mockRetentionAccess.mockReturnValue( + new Promise((resolve) => { + release = resolve + }) + ) + + const inflight = Promise.all([ + hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID), + hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID), + hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID), + ]) + + release(true) + + await expect(inflight).resolves.toEqual([true, true, true]) + expect(mockRetentionAccess).toHaveBeenCalledTimes(1) + }) + + it('keeps workspaces in separate cache entries', async () => { + mockRetentionAccess.mockImplementation(async (id: string) => id === WORKSPACE_ID) + + await expect(hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID)).resolves.toBe(true) + await expect(hasWorkspaceSandboxRetentionAccessCached('workspace-2')).resolves.toBe(false) + await expect(hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID)).resolves.toBe(true) + + expect(mockRetentionAccess).toHaveBeenCalledTimes(2) + }) + + /** + * The rejection path exists because the cached read asks for it. The + * one-shot gate maps a billing outage to `false` — indistinguishable from a + * real lapse — and caching that would fail every Function block in the + * workspace for the full TTL. + */ + it('asks billing to throw rather than report an outage as a lapse', async () => { + await hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID) + + expect(mockRetentionAccess).toHaveBeenCalledWith(WORKSPACE_ID, { onError: 'throw' }) + }) + + it('does not cache a rejection, so a transient failure cannot pin the gate shut', async () => { + mockRetentionAccess.mockRejectedValueOnce(new Error('billing read failed')) + + await expect(hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID)).rejects.toThrow( + 'billing read failed' + ) + await expect(hasWorkspaceSandboxRetentionAccessCached(WORKSPACE_ID)).resolves.toBe(true) + + expect(mockRetentionAccess).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/entitlement.ts b/apps/sim/lib/execution/remote-sandbox/entitlement.ts new file mode 100644 index 00000000000..c89e9111c29 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/entitlement.ts @@ -0,0 +1,76 @@ +import { LRUCache } from 'lru-cache' +import { coalesceLocally } from '@/lib/concurrency/singleflight' + +/** 403 copy for a workspace whose plan does not include Sim sandbox access. */ +export const MAX_PLAN_REQUIRED = 'Sim sandboxes require an active Max or Enterprise plan.' + +/** + * How long a resolved sandbox entitlement stays usable on the execution path. + * Matches the organization BYOK entitlement, the other plan gate cached this + * way. Staleness fails in the harmless direction: a workspace whose plan + * terminally lapsed keeps running attached sandboxes for at most this long, + * and every run is metered regardless, so nobody is charged wrongly. + */ +export const SANDBOX_ENTITLEMENT_TTL_MS = 60 * 1000 + +/** + * Resolved entitlements, with `LRUCache` supplying the TTL and the size bound. + * + * Values are booleans, so every read must test `!== undefined` — a plain + * truthiness check would treat a cached `false` as a miss and re-query a lapsed + * workspace on every single Function block. + */ +const entitlementCache = new LRUCache({ + max: 500, + ttl: SANDBOX_ENTITLEMENT_TTL_MS, +}) + +/** + * Whether a workspace may keep executing its attached sandboxes, with bounded + * staleness. + * + * `resolveWorkspaceSandbox` runs once per Function block, so a workflow looping + * over N items would otherwise pay N billing reads. React's request cache is a + * no-op in the Trigger.dev workers that run workflows, which is why this is a + * process cache rather than `cache()`. + * + * Billing is reached lazily for the same reason `resolve.ts` reaches the + * database lazily: the sandbox barrel is imported by the doc compilers and the + * parity script, which never select a workspace sandbox and must stay + * importable without a database. + */ +export async function hasWorkspaceSandboxRetentionAccessCached( + workspaceId: string +): Promise { + const cached = entitlementCache.get(workspaceId) + if (cached !== undefined) return cached + + /** + * `coalesceLocally` collapses a parallel or loop block's N simultaneous + * misses onto one resolution, and bounds a *hung* billing read at its settle + * deadline rather than wedging every caller for the whole TTL. + * + * The cache write stays out here, on the value this caller received. A caller + * that timed out throws instead of reaching it, and `onError: 'throw'` is what + * keeps a momentary outage from being recorded as a plan lapse: the resolver + * otherwise maps a failed read to `false` exactly like a real one. + */ + const entitled = await coalesceLocally(`sandbox-entitlement:${workspaceId}`, async () => { + const { hasWorkspaceSandboxRetentionAccess } = await import('@/lib/billing/core/subscription') + return hasWorkspaceSandboxRetentionAccess(workspaceId, { onError: 'throw' }) + }) + entitlementCache.set(workspaceId, entitled) + return entitled +} + +/** + * Drops every cached entitlement. Test seam; never called in production code. + * + * There is deliberately no per-workspace invalidator: plan changes arrive on a + * Stripe webhook, which lands in one process while the readers are per-worker, + * so an invalidator would look like it made a change immediate when it only + * cleared one process. The TTL is the real mechanism. + */ +export function resetSandboxEntitlementCache(): void { + entitlementCache.clear() +} diff --git a/apps/sim/lib/execution/remote-sandbox/resolve.test.ts b/apps/sim/lib/execution/remote-sandbox/resolve.test.ts index 2263606acb9..c85fa30887e 100644 --- a/apps/sim/lib/execution/remote-sandbox/resolve.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/resolve.test.ts @@ -15,6 +15,7 @@ const { mockEnsureSandboxImage, mockIsMissingImage, mockLocalGeneration, + mockPlanAccess, } = vi.hoisted(() => ({ mockSelect: vi.fn(), mockUpdate: vi.fn(), @@ -22,6 +23,7 @@ const { mockEnsureSandboxImage: vi.fn(), mockIsMissingImage: vi.fn(), mockLocalGeneration: { current: 1785792000000001 }, + mockPlanAccess: vi.fn(), })) vi.mock('@/lib/execution/remote-sandbox/image-registry', () => ({ @@ -29,6 +31,11 @@ vi.mock('@/lib/execution/remote-sandbox/image-registry', () => ({ FAILED_BUILD_RETRY_COOLDOWN_MS: 600_000, })) +vi.mock('@/lib/execution/remote-sandbox/entitlement', () => ({ + MAX_PLAN_REQUIRED: 'Sim sandboxes require an active Max or Enterprise plan.', + hasWorkspaceSandboxRetentionAccessCached: mockPlanAccess, +})) + vi.mock('@sim/db', () => ({ db: { select: mockSelect, @@ -127,6 +134,7 @@ beforeEach(() => { mockLocalGeneration.current = 1785792000000001 mockUpdate.mockReturnValue({ set: () => ({ where: () => Promise.resolve() }) }) mockEnsureSandboxImage.mockResolvedValue(undefined) + mockPlanAccess.mockResolvedValue(true) }) describe('resolveWorkspaceSandbox', () => { @@ -728,3 +736,67 @@ describe('repairMissingSandboxImage', () => { expect(mockEnsureSandboxImage).not.toHaveBeenCalled() }) }) + +/** + * Execution is gated on a *terminal* plan lapse only. A payment retry keeps + * running (the retention reader decides that); what is pinned here is that the + * gate fires before any row is read, never fires without a selection, and + * fails open when the plan cannot be read at all. + */ +describe('resolveWorkspaceSandbox plan gate', () => { + it('refuses a selection once the plan has lapsed, before reading the row', async () => { + mockPlanAccess.mockResolvedValue(false) + + await expect( + resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + ).rejects.toThrow('Max or Enterprise') + expect(mockPlanAccess).toHaveBeenCalledWith('ws-1') + expect(mockSelect).not.toHaveBeenCalled() + }) + + it('allows the selection when the plan cannot be read, rather than failing every block', async () => { + mockPlanAccess.mockRejectedValue(new Error('billing read failed')) + queueSelects( + [SANDBOX_ROW], + [{ status: 'ready', imageRef: 'sim-sbx-current:abc', errorCode: null, errorMessage: null }] + ) + + const resolved = await resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + + expect(resolved).toMatchObject({ strategy: 'prebuilt', imageRef: 'sim-sbx-current:abc' }) + }) + + it('never consults the plan without a selection', async () => { + await resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + }) + + expect(mockPlanAccess).not.toHaveBeenCalled() + }) + + it.each(['mothership', 'doc', 'pi'] as const)( + 'never consults the plan for the %s kind', + async (kind) => { + await resolveWorkspaceSandbox({ + kind, + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + + expect(mockPlanAccess).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/resolve.ts b/apps/sim/lib/execution/remote-sandbox/resolve.ts index 7cec40c7517..131410ee84d 100644 --- a/apps/sim/lib/execution/remote-sandbox/resolve.ts +++ b/apps/sim/lib/execution/remote-sandbox/resolve.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { CodeLanguage } from '@/lib/execution/languages' import { classifyInstallOutput, tailBuildLog } from '@/lib/execution/remote-sandbox/build-errors' import { @@ -11,6 +12,10 @@ import { sandboxCliToolRecipes, sandboxCliVerificationCommand, } from '@/lib/execution/remote-sandbox/cli-tools.server' +import { + hasWorkspaceSandboxRetentionAccessCached, + MAX_PLAN_REQUIRED, +} from '@/lib/execution/remote-sandbox/entitlement' import { MAX_SANDBOX_PROCESS_OUTPUT_BYTES } from '@/lib/execution/remote-sandbox/output-limits' import { resolveProvider } from '@/lib/execution/remote-sandbox/provider' import { @@ -165,6 +170,28 @@ function touchImage(specHash: string, provider: string): void { .catch((error) => logger.warn('Failed to record sandbox image use', { specHash, error })) } +/** + * Refuses a selection once the workspace's plan has terminally lapsed. + * + * Fails open when the plan cannot be read at all: this sits in front of every + * Function block on the execution path, and a billing-database blip must not + * become a fleet-wide run failure. The cached reader never records the outage, + * so the next block asks again. + */ +async function requireSandboxPlan(workspaceId: string): Promise { + let entitled: boolean + try { + entitled = await hasWorkspaceSandboxRetentionAccessCached(workspaceId) + } catch (error) { + logger.warn('Sandbox plan check unavailable; allowing the selected sandbox', { + workspaceId, + error: getErrorMessage(error), + }) + return + } + if (!entitled) throw new Error(MAX_PLAN_REQUIRED) +} + /** * Resolves the sandbox an execution should run against, or `null` when none is * selected (today's behavior: the env-configured template, no install step). @@ -174,9 +201,11 @@ function touchImage(specHash: string, provider: string): void { * throws with the reason, because the alternative is a baffling * `ModuleNotFoundError` inside the user's code. * - * Deliberately not plan-gated here: authoring and new Copilot selection are - * gated at their boundaries, while a workspace that downgrades keeps executing - * sandboxes already attached to Function blocks. + * Plan-gated on a terminal lapse only. Authoring and new Copilot selection are + * gated at their boundaries on a usable plan; execution reads the retention + * variant, so a payment retry never fails a running workflow, while a payer + * that cancelled or downgraded off Max/Enterprise fails closed with the plan + * message rather than keep running a feature the plan no longer includes. */ export async function resolveWorkspaceSandbox(args: { kind: SandboxKind @@ -195,6 +224,7 @@ export async function resolveWorkspaceSandbox(args: { if (!workspaceId) { throw new Error('A sandbox was selected but this execution has no workspace to resolve it in') } + await requireSandboxPlan(workspaceId) const provider = resolveProvider() const { db, sandboxImage, workspaceSandbox, and, eq } = await sandboxDb() diff --git a/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.test.ts b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.test.ts new file mode 100644 index 00000000000..7ccbca759de --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + * + * Pins where a write spends its admission: after the spec has validated and + * the name is known to be free, immediately before the write that schedules a + * build. A refused line or a name collision builds nothing, so it must not + * consume the budget a real build needs. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSelect, mockInsert, mockUpdate, calls } = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockInsert: vi.fn(), + mockUpdate: vi.fn(), + calls: [] as string[], +})) + +vi.mock('@sim/db', () => ({ + db: { select: mockSelect, insert: mockInsert, update: mockUpdate, delete: vi.fn() }, +})) +vi.mock('@sim/db/schema', () => ({ + workspaceSandbox: { + id: 'id', + workspaceId: 'workspace_id', + name: 'name', + language: 'language', + dependencies: 'dependencies', + cliTools: 'cli_tools', + systemPackages: 'system_packages', + specHash: 'spec_hash', + createdBy: 'created_by', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, + sandboxImage: { + provider: 'provider', + specHash: 'spec_hash', + status: 'status', + errorCode: 'error_code', + errorMessage: 'error_message', + errorDetail: 'error_detail', + updatedAt: 'updated_at', + }, +})) +vi.mock('drizzle-orm', () => { + const sql = Object.assign((strings: TemplateStringsArray) => ({ sql: strings.join('?') }), { + param: (value: unknown) => value, + raw: (value: unknown) => value, + }) + return { + and: (...args: unknown[]) => args, + eq: (...args: unknown[]) => args, + gt: (...args: unknown[]) => args, + lt: (...args: unknown[]) => args, + or: (...args: unknown[]) => args, + ilike: (...args: unknown[]) => args, + inArray: (...args: unknown[]) => args, + asc: (value: unknown) => value, + desc: (value: unknown) => value, + sql, + } +}) +vi.mock('@/lib/execution/remote-sandbox/provider', () => ({ + resolveProvider: () => ({ id: 'e2b', dependencyStrategy: 'runtime' }), +})) +vi.mock('@/lib/execution/remote-sandbox/cli-tools.server', () => ({ + assertSandboxCliToolsSupported: vi.fn(), +})) +vi.mock('@/lib/execution/remote-sandbox/image-registry', () => ({ + ensureSandboxImage: vi.fn(), + releaseSandboxImage: vi.fn(), +})) +vi.mock('@/lib/execution/remote-sandbox/resolve', () => ({ + invalidateSandboxResolution: vi.fn(), +})) +vi.mock('@/lib/core/utils/background', () => ({ + runDetached: vi.fn(), +})) + +import { + createWorkspaceSandbox, + SandboxDependencyError, + updateWorkspaceSandbox, + WorkspaceSandboxNameConflictError, +} from '@/lib/execution/remote-sandbox/workspace-sandboxes' + +const WORKSPACE_ID = 'workspace-1' +const ROW = { + id: 'sandbox-1', + name: 'data-tools', + language: 'python', + dependencies: ['pandas'], + cliTools: [], + systemPackages: [], + specHash: 'hash-1', + createdAt: new Date('2026-08-04T11:00:00Z'), + updatedAt: new Date('2026-08-04T12:00:00Z'), +} + +/** Queues the rows each successive `db.select()` chain resolves to. */ +function queueSelects(...results: unknown[][]) { + mockSelect.mockReset() + for (const rows of results) { + mockSelect.mockReturnValueOnce({ + from: () => ({ where: () => ({ limit: () => Promise.resolve(rows) }) }), + }) + } +} + +const admit = vi.fn(async () => { + calls.push('admit') +}) + +describe('sandbox write admission', () => { + beforeEach(() => { + vi.clearAllMocks() + calls.length = 0 + mockInsert.mockReturnValue({ + values: async () => { + calls.push('insert') + }, + }) + mockUpdate.mockReturnValue({ + set: () => ({ + where: async () => { + calls.push('update') + }, + }), + }) + }) + + it('admits a create only after the spec validated and the name is free, before the write', async () => { + queueSelects([], [ROW]) + + const sandbox = await createWorkspaceSandbox( + WORKSPACE_ID, + 'user-1', + { name: 'data-tools', language: 'python', dependencies: ['pandas'] }, + { admit } + ) + + expect(sandbox.id).toBe('sandbox-1') + expect(calls).toEqual(['admit', 'insert']) + }) + + it('refuses an invalid dependency before admission', async () => { + queueSelects([]) + + await expect( + createWorkspaceSandbox( + WORKSPACE_ID, + 'user-1', + { name: 'data-tools', language: 'python', dependencies: ['not a package!'] }, + { admit } + ) + ).rejects.toBeInstanceOf(SandboxDependencyError) + expect(admit).not.toHaveBeenCalled() + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('refuses a taken name before admission', async () => { + queueSelects([{ id: 'sandbox-other' }]) + + await expect( + createWorkspaceSandbox( + WORKSPACE_ID, + 'user-1', + { name: 'data-tools', language: 'python', dependencies: ['pandas'] }, + { admit } + ) + ).rejects.toBeInstanceOf(WorkspaceSandboxNameConflictError) + expect(admit).not.toHaveBeenCalled() + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('admits an edit only after the merged spec validated, before the write', async () => { + queueSelects([ROW], [ROW]) + + await updateWorkspaceSandbox( + WORKSPACE_ID, + ROW.id, + { dependencies: ['pandas', 'numpy'] }, + { admit } + ) + + expect(calls).toEqual(['admit', 'update']) + }) + + it('refuses an invalid edit before admission', async () => { + queueSelects([ROW]) + + await expect( + updateWorkspaceSandbox(WORKSPACE_ID, ROW.id, { dependencies: ['bad line!'] }, { admit }) + ).rejects.toBeInstanceOf(SandboxDependencyError) + expect(admit).not.toHaveBeenCalled() + expect(mockUpdate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts index eca01e212bd..d092c4a7947 100644 --- a/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts +++ b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts @@ -4,6 +4,19 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import type { CreateSandboxBody, Sandbox, UpdateSandboxBody } from '@/lib/api/contracts/sandboxes' +import { + type CursorKey, + type KeysetKey, + keysetColumns, + keysetPage, + type ListSortOrder, + listOrderBy, + resumeKeyset, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' import { canonicalizeSandboxCliTools, @@ -27,11 +40,6 @@ import { } from '@/lib/execution/remote-sandbox/sandbox-spec' import type { SandboxDependencyStrategy } from '@/lib/execution/remote-sandbox/types' -/** 403 copy for a workspace whose plan does not include Sim sandbox access. */ -export const MAX_PLAN_REQUIRED = 'Sim sandboxes require an active Max or Enterprise plan.' - -export const SANDBOX_ADMIN_REQUIRED = 'Only workspace admins can manage Sim sandboxes' - /** * The unique index that actually arbitrates sandbox-name collisions. Named here * so a write path can recognize losing the race and answer 409 rather than 500. @@ -39,7 +47,8 @@ export const SANDBOX_ADMIN_REQUIRED = 'Only workspace admins can manage Sim sand export const WORKSPACE_SANDBOX_NAME_INDEX = 'workspace_sandbox_workspace_name_unique' /** - * Builds cost provider compute, so every mutation shares one per-workspace + * Saves cost provider work — a prebuilt image, or a re-install on the next run + * under a runtime provider — so creates and updates share one per-workspace * budget rather than giving each admin a full allowance of their own. */ export const SANDBOX_MUTATION_LIMIT = { @@ -48,32 +57,38 @@ export const SANDBOX_MUTATION_LIMIT = { refillIntervalMs: 60_000, } as const -/** Thrown when a submitted dependency list has lines the editor should mark. */ -export class SandboxDependencyError extends Error { +/** + * Thrown when a submitted dependency list has lines the editor should mark. + * + * Classified as `validation` so every surface answers 400 without restating the + * mapping; the surfaces read `issues` off it to address each refusal to the + * submitted line. + */ +export class SandboxDependencyError extends OrchestrationError { constructor(readonly issues: DependencyIssue[]) { - super(issues[0]?.reason ?? 'Invalid dependency list') + super('validation', issues[0]?.reason ?? 'Invalid dependency list') this.name = 'SandboxDependencyError' } } /** Thrown when a submitted Debian package coordinate is invalid. */ -export class SandboxSystemPackageError extends Error { +export class SandboxSystemPackageError extends OrchestrationError { constructor(readonly issues: SystemPackageIssue[]) { - super(issues[0]?.reason ?? 'Invalid system package list') + super('validation', issues[0]?.reason ?? 'Invalid system package list') this.name = 'SandboxSystemPackageError' } } -export class WorkspaceSandboxNotFoundError extends Error { +export class WorkspaceSandboxNotFoundError extends OrchestrationError { constructor() { - super('Sandbox not found') + super('not_found', 'Sandbox not found') this.name = 'WorkspaceSandboxNotFoundError' } } -export class WorkspaceSandboxNameConflictError extends Error { +export class WorkspaceSandboxNameConflictError extends OrchestrationError { constructor(readonly sandboxName: string) { - super(`A sandbox named "${sandboxName}" already exists in this workspace`) + super('conflict', `A sandbox named "${sandboxName}" already exists in this workspace`) this.name = 'WorkspaceSandboxNameConflictError' } } @@ -86,6 +101,16 @@ export interface SandboxSpecUpdate { specHash: string } +export interface SandboxWriteOptions { + /** + * Runs once the spec has validated and the name is known to be free, + * immediately before the write that schedules a build. The build budget is + * spent here rather than up front so a refused line or a name collision, + * which build nothing, cannot drain it. + */ + admit?: () => Promise +} + /** * Validates a submitted list against the target language and returns the * canonical spec. Called on every write, including a language change, so a list @@ -205,18 +230,73 @@ const SANDBOX_COLUMNS = { updatedAt: workspaceSandbox.updatedAt, } as const +export type SandboxSortBy = 'name' | 'createdAt' | 'updatedAt' + +type SandboxListRow = SandboxRow & { specHash: string } + +/** The tiebreaker every ordering ends in, so a page boundary inside a tie is exact. */ +const sandboxIdKey = textKey(workspaceSandbox.id, (row) => row.id) + +const SANDBOX_SORTS = { + name: [textKey(workspaceSandbox.name, (row) => row.name), sandboxIdKey], + createdAt: [ + timestampKey(workspaceSandbox.createdAt, (row) => row.createdAt), + sandboxIdKey, + ], + updatedAt: [ + timestampKey(workspaceSandbox.updatedAt, (row) => row.updatedAt), + sandboxIdKey, + ], +} satisfies Record[]> + +export interface WorkspaceSandboxPage { + data: Sandbox[] + nextCursorKeys: CursorKey[] | null +} + /** - * Lists a workspace's sandboxes with their build status. Under a runtime - * provider the registry is never consulted, because it is never written. + * Lists a workspace's sandboxes with their build status, optionally searched, + * sorted, and keyset-paged. Without `limit` the whole set comes back in one + * read and can never carry a cursor, which is what the settings page and + * Copilot want; the public API passes one and resumes from `cursorKeys`. + * + * Under a runtime provider the build registry is never consulted, because it + * is never written. */ -export async function listWorkspaceSandboxes(workspaceId: string): Promise { - const rows = await db +export async function listWorkspaceSandboxesPage(params: { + workspaceId: string + /** Case-insensitive substring match on the sandbox name. */ + search?: string + sortBy?: SandboxSortBy + sortOrder?: ListSortOrder + limit?: number + cursorKeys?: CursorKey[] +}): Promise { + const { sortBy = 'name', sortOrder = 'asc', limit } = params + const keys = SANDBOX_SORTS[sortBy] + const resumeAfter = resumeKeyset(keys, params.cursorKeys, sortOrder) + + const query = db .select(SANDBOX_COLUMNS) .from(workspaceSandbox) - .where(eq(workspaceSandbox.workspaceId, workspaceId)) - .orderBy(workspaceSandbox.name) + .where( + and( + eq(workspaceSandbox.workspaceId, params.workspaceId), + searchFilter(workspaceSandbox.name, params.search), + resumeAfter + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), sortOrder)) + const rows = limit === undefined ? await query : await query.limit(limit + 1) + + const page = keysetPage(keys, rows, limit) + return { data: await attachBuildStatus(page.data), nextCursorKeys: page.nextCursorKeys } +} - return attachBuildStatus(rows) +/** The whole set, name-ordered, for the surfaces that render every sandbox at once. */ +export async function listWorkspaceSandboxes(workspaceId: string): Promise { + const page = await listWorkspaceSandboxesPage({ workspaceId }) + return page.data } /** @@ -282,7 +362,8 @@ export function isWorkspaceSandboxNameConflictError(error: unknown): boolean { export async function createWorkspaceSandbox( workspaceId: string, createdBy: string, - input: CreateSandboxBody + input: CreateSandboxBody, + options: SandboxWriteOptions = {} ): Promise { const spec = buildSpecUpdate( input.language, @@ -294,6 +375,7 @@ export async function createWorkspaceSandbox( if (await isSandboxNameTaken(workspaceId, input.name)) { throw new WorkspaceSandboxNameConflictError(input.name) } + await options.admit?.() const id = generateId() try { @@ -329,7 +411,8 @@ export async function createWorkspaceSandbox( export async function updateWorkspaceSandbox( workspaceId: string, sandboxId: string, - input: UpdateSandboxBody + input: UpdateSandboxBody, + options: SandboxWriteOptions = {} ): Promise { const [existing] = await db .select({ @@ -362,6 +445,7 @@ export async function updateWorkspaceSandbox( input.cliTools ?? existing.cliTools ?? [], input.systemPackages ?? existing.systemPackages ?? [] ) + await options.admit?.() try { await db diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index c6e67edae20..df0d117c819 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -55,6 +55,7 @@ export const CAPABILITY_IDS = [ 'cli.use', 'triggers.webhook', 'copilot.tool_auto_approval', + 'sandboxes.use', ] as const export type PermissionGroupCapability = (typeof CAPABILITY_IDS)[number] @@ -407,6 +408,13 @@ export const CAPABILITY_RULES = { describe: 'Silencing a tool confirmation', deniedBy: (config) => config.disableToolAutoApproval, }, + 'sandboxes.use': { + kind: 'static', + configKeys: ['hideSandboxesTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'The Sandboxes module', + deniedBy: (config) => config.hideSandboxesTab, + }, } satisfies { readonly [K in PermissionGroupCapability]: CapabilityRule } /** diff --git a/apps/sim/lib/permission-groups/fields.test.ts b/apps/sim/lib/permission-groups/fields.test.ts index e5ae2c82d0d..5b9ad4cf981 100644 --- a/apps/sim/lib/permission-groups/fields.test.ts +++ b/apps/sim/lib/permission-groups/fields.test.ts @@ -163,6 +163,7 @@ const fixtures: readonly CoercionFixture[] = [ disableCliAccess: true, disableWebhookTriggers: true, disableToolAutoApproval: true, + hideSandboxesTab: true, }, expected: { allowedIntegrations: ['slack_v2'], @@ -204,6 +205,7 @@ const fixtures: readonly CoercionFixture[] = [ disableCliAccess: true, disableWebhookTriggers: true, disableToolAutoApproval: true, + hideSandboxesTab: true, }, }, ] diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index 79b2d92e6fa..aec07f5de45 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -480,6 +480,13 @@ export const PERMISSION_GROUP_FIELDS = { category: 'Tools', hint: 'Prevent silencing a tool confirmation, so every call is confirmed again.', }), + hideSandboxesTab: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-sandboxes', + label: 'Sandboxes', + category: 'Modules', + hint: 'Revoke the Sandboxes module. Members cannot view, create, or change a workspace sandbox.', + }), } satisfies Record export type PermissionGroupFields = typeof PERMISSION_GROUP_FIELDS diff --git a/apps/sim/lib/sandboxes/application/authorization.ts b/apps/sim/lib/sandboxes/application/authorization.ts new file mode 100644 index 00000000000..bc5ee4140a8 --- /dev/null +++ b/apps/sim/lib/sandboxes/application/authorization.ts @@ -0,0 +1,16 @@ +import type { WorkspaceDelegationPolicy } from '@/lib/core/application' + +export const SANDBOX_DELEGATION_AUDIENCE = 'sim:sandboxes' + +/** + * Copilot acts on sandboxes as the person it serves, within the workspace the + * server bound the tool call to; no narrower resource scope applies. + */ +export const sandboxDelegationPolicy = { + audience: SANDBOX_DELEGATION_AUDIENCE, + isWithinScope: () => true, +} as const satisfies WorkspaceDelegationPolicy<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +}> diff --git a/apps/sim/lib/sandboxes/application/build-budget.ts b/apps/sim/lib/sandboxes/application/build-budget.ts new file mode 100644 index 00000000000..5cdf4b464c2 --- /dev/null +++ b/apps/sim/lib/sandboxes/application/build-budget.ts @@ -0,0 +1,55 @@ +import { RateLimiter } from '@/lib/core/rate-limiter' +import { HttpError } from '@/lib/core/utils/http-error' +import { SANDBOX_MUTATION_LIMIT } from '@/lib/execution/remote-sandbox/workspace-sandboxes' + +const rateLimiter = new RateLimiter() + +/** + * The bucket the legacy routes consumed, kept byte-identical so the budget + * carries across the deploy and stays one budget however a mutation arrives. + */ +const budgetKey = (workspaceId: string) => `route:sandbox-mutations:workspace:${workspaceId}` + +/** + * Thrown when a workspace has spent its build budget for the window. + * + * An `HttpError` rather than a bare `Error` so a surface that has not mapped it + * still answers 429 instead of 500; the mapped surfaces add `Retry-After`. + */ +export class SandboxBuildBudgetExceededError extends HttpError { + readonly statusCode = 429 + + constructor( + readonly resetAt: Date, + readonly retryAfterMs?: number + ) { + super('Rate limit exceeded') + this.name = 'SandboxBuildBudgetExceededError' + } + + /** Whole seconds until the bucket refills, never below one. */ + get retryAfterSeconds(): number { + const waitMs = this.retryAfterMs ?? this.resetAt.getTime() - Date.now() + return Math.max(1, Math.ceil(waitMs / 1000)) + } +} + +/** + * Consumes one token of the workspace's sandbox write budget or throws. + * + * Saves cost provider work — a prebuilt image, or a re-install on the next run + * under a runtime provider — so creates and updates share one per-workspace + * budget rather than each admin getting a full allowance. This is cost + * admission, not request-rate limiting: it runs inside the use case so the + * internal API, the public API, and Copilot all draw on the same bucket, and it + * runs after authorization so an unauthorized caller cannot drain it. Fails + * open on a limiter outage, as the legacy routes did. + */ +export async function assertSandboxBuildBudget(workspaceId: string): Promise { + const budget = await rateLimiter.checkRateLimitDirect( + budgetKey(workspaceId), + SANDBOX_MUTATION_LIMIT + ) + if (budget.allowed) return + throw new SandboxBuildBudgetExceededError(budget.resetAt, budget.retryAfterMs) +} diff --git a/apps/sim/lib/sandboxes/application/operations.test.ts b/apps/sim/lib/sandboxes/application/operations.test.ts new file mode 100644 index 00000000000..45840fde188 --- /dev/null +++ b/apps/sim/lib/sandboxes/application/operations.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), +})) + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import type { WorkspaceOperation } from '@/lib/core/application' +import { authorizeWorkspaceOperation, PermissionGroupCapabilityError } from '@/lib/core/application' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { sandboxOperations } from '@/lib/sandboxes/application/operations' + +describe('sandbox operation registry', () => { + it('lets any member, and a workspace key, read what the workspace built', () => { + for (const operation of [sandboxOperations.list, sandboxOperations.read]) { + expect(operation).toMatchObject({ + minimumRole: 'read', + workspaceApiKey: 'allow', + capability: 'sandboxes.use', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], + }) + } + }) + + /** + * Builds cost provider compute, so every write is an admin decision. The + * admin ceiling is also what denies workspace keys: each write resolves the + * acting person as the sandbox's creator, which a workspace key cannot + * supply, so allowing one would replace a `403` with an unclassified throw. + */ + it('reserves every write for a workspace admin acting as a person', () => { + for (const operation of [ + sandboxOperations.create, + sandboxOperations.update, + sandboxOperations.delete, + ]) { + expect(operation).toMatchObject({ + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'sandboxes.use', + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], + }) + } + }) + + it('cannot resolve an acting subject for a workspace key', () => { + expect(() => + requirePrincipalSubjectUserId({ + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }) + ).toThrow(/does not represent a human subject/) + }) + + it('uses unique stable operation IDs', () => { + const ids = Object.values(sandboxOperations).map((operation) => operation.id) + expect(new Set(ids).size).toBe(ids.length) + expect(ids).toEqual([ + 'sandboxes.list', + 'sandboxes.read', + 'sandboxes.create', + 'sandboxes.update', + 'sandboxes.delete', + ]) + }) +}) + +const sessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, +} + +/** + * The declaration is only half the gate. These call the funnel so the + * capability cannot be declared on the operations and then read by nothing. + */ +describe('sandbox operations under a group that withholds the module', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + }) + + it('refuses every operation, reads included', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideSandboxesTab: true, + }) + + for (const operation of Object.values(sandboxOperations)) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + } + }) + + it('allows them all when the group withholds nothing', async () => { + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + for (const operation of Object.values(sandboxOperations)) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).resolves.toBeUndefined() + } + }) +}) diff --git a/apps/sim/lib/sandboxes/application/operations.ts b/apps/sim/lib/sandboxes/application/operations.ts new file mode 100644 index 00000000000..7b871e6bab8 --- /dev/null +++ b/apps/sim/lib/sandboxes/application/operations.ts @@ -0,0 +1,61 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const ALL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const +const HUMAN_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const + +/** + * Every operation declares `sandboxes.use`, reads included: a group that + * withholds the module has no use for its listing either. + * + * Reads sit at `read` and are not plan-gated. A workspace that dropped below + * the Max tier must still see what it built, and the list carries `entitled` + * so a surface knows whether authoring will be refused. Writes are `admin`, + * because builds cost provider compute, and the admin ceiling is also what + * denies workspace API keys: the write use cases resolve the acting human as + * the sandbox's creator, which a workspace key cannot supply. + */ +export const sandboxOperations = { + list: defineWorkspaceOperation({ + id: 'sandboxes.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + capability: 'sandboxes.use', + ...ALL_PRINCIPAL_POLICY, + }), + read: defineWorkspaceOperation({ + id: 'sandboxes.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + capability: 'sandboxes.use', + ...ALL_PRINCIPAL_POLICY, + }), + create: defineWorkspaceOperation({ + id: 'sandboxes.create', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'sandboxes.use', + ...HUMAN_PRINCIPAL_POLICY, + }), + update: defineWorkspaceOperation({ + id: 'sandboxes.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'sandboxes.use', + ...HUMAN_PRINCIPAL_POLICY, + }), + delete: defineWorkspaceOperation({ + id: 'sandboxes.delete', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'sandboxes.use', + ...HUMAN_PRINCIPAL_POLICY, + }), +} as const + +export type SandboxOperation = (typeof sandboxOperations)[keyof typeof sandboxOperations] diff --git a/apps/sim/lib/sandboxes/application/use-cases.test.ts b/apps/sim/lib/sandboxes/application/use-cases.test.ts new file mode 100644 index 00000000000..e01f0524c92 --- /dev/null +++ b/apps/sim/lib/sandboxes/application/use-cases.test.ts @@ -0,0 +1,472 @@ +/** + * @vitest-environment node + */ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks } = vi.hoisted(() => ({ + mocks: { + loadContext: vi.fn(), + resolvePermission: vi.fn(), + hasAccess: vi.fn(), + budget: vi.fn(), + listPage: vi.fn(), + read: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + audit: vi.fn(), + }, +})) + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + loadActiveWorkspaceContext: mocks.loadContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@sim/audit', () => ({ + AuditAction: { + SANDBOX_CREATED: 'sandbox.created', + SANDBOX_UPDATED: 'sandbox.updated', + SANDBOX_DELETED: 'sandbox.deleted', + }, + AuditResourceType: { SANDBOX: 'sandbox' }, + recordAudit: mocks.audit, +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceSandboxAccess: mocks.hasAccess, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.budget + }, +})) +vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', () => ({ + SANDBOX_MUTATION_LIMIT: { maxTokens: 20, refillRate: 10, refillIntervalMs: 60_000 }, + createWorkspaceSandbox: mocks.create, + currentSandboxStrategy: () => 'prebuilt', + deleteWorkspaceSandbox: mocks.remove, + listWorkspaceSandboxesPage: mocks.listPage, + readWorkspaceSandbox: mocks.read, + updateWorkspaceSandbox: mocks.update, +})) + +import { + ForbiddenOperationError, + InsufficientWorkspacePermissionsError, + PermissionGroupCapabilityError, + WorkspaceApiKeyAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { SANDBOX_DELEGATION_AUDIENCE } from '@/lib/sandboxes/application/authorization' +import { SandboxBuildBudgetExceededError } from '@/lib/sandboxes/application/build-budget' +import { + createWorkspaceSandboxUseCase, + deleteWorkspaceSandboxUseCase, + getWorkspaceSandboxUseCase, + listWorkspaceSandboxesUseCase, + updateWorkspaceSandboxUseCase, +} from '@/lib/sandboxes/application/use-cases' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', +} +const sandbox = { + id: 'sandbox-1', + name: 'data-tools', + language: 'python' as const, + dependencies: ['pandas'], + cliTools: [], + systemPackages: ['graphviz'], + buildStatus: 'ready' as const, + errorCode: null, + errorMessage: null, + errorDetail: null, + builtAt: '2026-08-04T12:00:00.000Z', + createdAt: '2026-08-04T11:00:00.000Z', + updatedAt: '2026-08-04T12:00:00.000Z', +} +const session = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const workspaceKey = { + kind: 'workspace_api_key' as const, + workspaceId: workspace.workspaceId, + keyId: 'workspace-key-1', +} +const BUDGET_OK = { allowed: true, remaining: 19, resetAt: new Date('2026-08-04T12:01:00Z') } +const createInput = { + workspaceId: workspace.workspaceId, + name: 'data-tools', + language: 'python' as const, + dependencies: ['pandas'], + source: 'api' as const, +} + +function copilotPrincipal(overrides: Partial = {}): DelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: workspace.workspaceId, + delegationId: 'copilot-tool:call-1', + audience: SANDBOX_DELEGATION_AUDIENCE, + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + ...overrides, + } +} + +describe('sandbox application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('admin') + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + mocks.hasAccess.mockResolvedValue(true) + mocks.budget.mockResolvedValue(BUDGET_OK) + mocks.listPage.mockResolvedValue({ data: [sandbox], nextCursorKeys: null }) + mocks.read.mockResolvedValue(sandbox) + mocks.create.mockImplementation(async (_workspaceId, _createdBy, _input, options) => { + await options?.admit?.() + return sandbox + }) + mocks.update.mockImplementation(async (_workspaceId, _sandboxId, _input, options) => { + await options?.admit?.() + return sandbox + }) + mocks.remove.mockResolvedValue(undefined) + }) + + describe('list', () => { + it('reads at member role, reports entitlement, and never spends the build budget', async () => { + mocks.resolvePermission.mockResolvedValue('read') + mocks.hasAccess.mockResolvedValue(false) + + const result = await listWorkspaceSandboxesUseCase.execute({ + principal: session, + input: { workspaceId: workspace.workspaceId, sortBy: 'name', sortOrder: 'asc' }, + }) + + expect(result).toEqual({ + sandboxes: [sandbox], + nextCursorKeys: null, + strategy: 'prebuilt', + entitled: false, + sortBy: 'name', + sortOrder: 'asc', + }) + expect(mocks.listPage).toHaveBeenCalledWith({ + workspaceId: workspace.workspaceId, + sortBy: 'name', + sortOrder: 'asc', + }) + expect(mocks.budget).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('pages for a workspace API key and hands back the resume keys', async () => { + mocks.listPage.mockResolvedValue({ + data: [sandbox], + nextCursorKeys: ['data-tools', 'sandbox-1'], + }) + + const result = await listWorkspaceSandboxesUseCase.execute({ + principal: workspaceKey, + input: { + workspaceId: workspace.workspaceId, + search: 'data', + sortBy: 'name', + sortOrder: 'asc', + limit: 1, + cursorKeys: undefined, + }, + }) + + expect(result.nextCursorKeys).toEqual(['data-tools', 'sandbox-1']) + expect(mocks.listPage).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: workspace.workspaceId, search: 'data', limit: 1 }) + ) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + }) + }) + + describe('read', () => { + it('conceals a sandbox from another workspace as absent', async () => { + mocks.read.mockResolvedValue(null) + + await expect( + getWorkspaceSandboxUseCase.execute({ + principal: session, + input: { workspaceId: workspace.workspaceId, sandboxId: 'sandbox-elsewhere' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Sandbox not found' }) + expect(mocks.read).toHaveBeenCalledWith(workspace.workspaceId, 'sandbox-elsewhere') + }) + }) + + describe('create', () => { + it('admits an admin on a Max plan, records the actor as creator, and audits the result', async () => { + const result = await createWorkspaceSandboxUseCase.execute({ + principal: session, + input: createInput, + }) + + expect(result).toEqual({ sandbox }) + expect(mocks.hasAccess).toHaveBeenCalledWith(workspace.workspaceId) + expect(mocks.budget).toHaveBeenCalledWith( + 'route:sandbox-mutations:workspace:workspace-1', + expect.objectContaining({ maxTokens: 20 }) + ) + expect(mocks.create).toHaveBeenCalledWith( + workspace.workspaceId, + 'user-1', + { + name: 'data-tools', + language: 'python', + dependencies: ['pandas'], + cliTools: [], + systemPackages: [], + }, + { admit: expect.any(Function) } + ) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: workspace.workspaceId, + actorId: 'user-1', + action: 'sandbox.created', + resourceType: 'sandbox', + resourceId: sandbox.id, + resourceName: sandbox.name, + metadata: expect.objectContaining({ + source: 'api', + language: 'python', + operation: 'sandboxes.create', + }), + }) + ) + }) + + it('refuses a workspace key before loading anything', async () => { + await expect( + createWorkspaceSandboxUseCase.execute({ principal: workspaceKey, input: createInput }) + ).rejects.toBeInstanceOf(WorkspaceApiKeyAuthorizationError) + expect(mocks.loadContext).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('refuses a writer who is not an admin, before the plan or the budget', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + await expect( + createWorkspaceSandboxUseCase.execute({ principal: session, input: createInput }) + ).rejects.toBeInstanceOf(InsufficientWorkspacePermissionsError) + expect(mocks.hasAccess).not.toHaveBeenCalled() + expect(mocks.budget).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('refuses a cohort whose permission group withholds the module', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideSandboxesTab: true, + }) + + await expect( + createWorkspaceSandboxUseCase.execute({ principal: session, input: createInput }) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('names the plan as the remedy below the Max tier and spends no budget', async () => { + mocks.hasAccess.mockResolvedValue(false) + + const failure = await createWorkspaceSandboxUseCase + .execute({ principal: session, input: createInput }) + .catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(ForbiddenOperationError) + expect(failure).toMatchObject({ + detailCode: 'WORKSPACE_PLAN_CAPABILITY_REQUIRED', + message: expect.stringContaining('Max or Enterprise'), + }) + expect(mocks.budget).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('refuses once the workspace build budget is spent, without writing', async () => { + mocks.budget.mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 30_000), + retryAfterMs: 30_000, + }) + + await expect( + createWorkspaceSandboxUseCase.execute({ principal: session, input: createInput }) + ).rejects.toBeInstanceOf(SandboxBuildBudgetExceededError) + expect(mocks.audit).not.toHaveBeenCalled() + }) + + /** + * The budget is the manager's admission hook, not an up-front charge: a + * spec the manager refuses, or a name it finds taken, never reaches the + * hook, so a stream of rejected requests cannot drain what a real build + * needs. The manager test pins where the hook fires; this pins that the + * use case spends nothing on its own. + */ + it('spends the budget only through the admission hook the manager owns', async () => { + mocks.create.mockRejectedValue(new OrchestrationError('validation', 'invalid package name')) + + await expect( + createWorkspaceSandboxUseCase.execute({ principal: session, input: createInput }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.budget).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('lets the manager report a name collision as a conflict', async () => { + mocks.create.mockRejectedValue( + new OrchestrationError('conflict', 'A sandbox named "data-tools" already exists') + ) + + await expect( + createWorkspaceSandboxUseCase.execute({ principal: session, input: createInput }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('acts as the person behind a trusted Copilot delegation', async () => { + await createWorkspaceSandboxUseCase.execute({ + principal: copilotPrincipal(), + input: { ...createInput, source: 'tool_input' }, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + workspace.workspaceId, + workspace.workspaceOrganizationId, + undefined, + expect.anything() + ) + expect(mocks.create).toHaveBeenCalledWith( + workspace.workspaceId, + 'user-1', + expect.objectContaining({ name: 'data-tools' }), + { admit: expect.any(Function) } + ) + }) + + it('rejects a delegation minted for another audience', async () => { + await expect( + createWorkspaceSandboxUseCase.execute({ + principal: copilotPrincipal({ audience: 'sim:custom-tools' }), + input: createInput, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.create).not.toHaveBeenCalled() + }) + }) + + describe('update', () => { + it('applies the supplied fields to the canonical sandbox and audits it', async () => { + const result = await updateWorkspaceSandboxUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + sandboxId: sandbox.id, + dependencies: ['pandas', 'numpy'], + source: 'settings', + }, + }) + + expect(result).toEqual({ sandbox }) + expect(mocks.update).toHaveBeenCalledWith( + workspace.workspaceId, + sandbox.id, + { + name: undefined, + language: undefined, + dependencies: ['pandas', 'numpy'], + cliTools: undefined, + systemPackages: undefined, + }, + { admit: expect.any(Function) } + ) + expect(mocks.budget).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'sandbox.updated', resourceId: sandbox.id }) + ) + }) + + it('answers not found before admission for a sandbox the workspace does not hold', async () => { + mocks.read.mockResolvedValue(null) + + await expect( + updateWorkspaceSandboxUseCase.execute({ + principal: session, + input: { workspaceId: workspace.workspaceId, sandboxId: 'missing', name: 'renamed' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.hasAccess).not.toHaveBeenCalled() + expect(mocks.update).not.toHaveBeenCalled() + }) + }) + + describe('delete', () => { + it('deletes the canonical sandbox and audits from the record it held', async () => { + const result = await deleteWorkspaceSandboxUseCase.execute({ + principal: session, + input: { workspaceId: workspace.workspaceId, sandboxId: sandbox.id, source: 'api' }, + }) + + expect(result).toEqual({ sandbox }) + expect(mocks.remove).toHaveBeenCalledWith(workspace.workspaceId, sandbox.id) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'sandbox.deleted', + resourceId: sandbox.id, + resourceName: sandbox.name, + }) + ) + }) + + /** + * Deleting builds nothing, and a workspace that spent its budget on saves + * must still be able to clean up. + */ + it('never spends the write budget on a delete', async () => { + mocks.budget.mockResolvedValue({ allowed: false, remaining: 0, resetAt: new Date() }) + + await expect( + deleteWorkspaceSandboxUseCase.execute({ + principal: session, + input: { workspaceId: workspace.workspaceId, sandboxId: sandbox.id }, + }) + ).resolves.toEqual({ sandbox }) + expect(mocks.budget).not.toHaveBeenCalled() + }) + + it('does not audit a delete the manager refused', async () => { + mocks.remove.mockRejectedValue(new OrchestrationError('not_found', 'Sandbox not found')) + + await expect( + deleteWorkspaceSandboxUseCase.execute({ + principal: session, + input: { workspaceId: workspace.workspaceId, sandboxId: sandbox.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.audit).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/sandboxes/application/use-cases.ts b/apps/sim/lib/sandboxes/application/use-cases.ts new file mode 100644 index 00000000000..c523fb72c83 --- /dev/null +++ b/apps/sim/lib/sandboxes/application/use-cases.ts @@ -0,0 +1,241 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' +import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { SandboxCliToolId } from '@/lib/execution/remote-sandbox/cli-tools' +import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/entitlement' +import type { SandboxLanguage } from '@/lib/execution/remote-sandbox/sandbox-spec' +import { + createWorkspaceSandbox, + currentSandboxStrategy, + deleteWorkspaceSandbox, + listWorkspaceSandboxesPage, + readWorkspaceSandbox, + type SandboxSortBy, + updateWorkspaceSandbox, +} from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { sandboxDelegationPolicy } from '@/lib/sandboxes/application/authorization' +import { assertSandboxBuildBudget } from '@/lib/sandboxes/application/build-budget' +import { sandboxOperations } from '@/lib/sandboxes/application/operations' +import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace' + +/** Which surface wrote, recorded on the audit entry only. */ +type SandboxWriteSource = 'api' | 'settings' | 'tool_input' + +interface SandboxWorkspaceContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +type SandboxRecord = NonNullable>> + +interface SandboxContext extends SandboxWorkspaceContext { + sandbox: SandboxRecord +} + +async function resolveWorkspaceContext(workspaceId: string): Promise { + const context = await loadActiveWorkspaceContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +async function resolveSandboxContext( + workspaceId: string, + sandboxId: string +): Promise { + const workspace = await resolveWorkspaceContext(workspaceId) + const sandbox = await readWorkspaceSandbox(workspace.workspaceId, sandboxId) + if (!sandbox) throw new OrchestrationError('not_found', 'Sandbox not found') + return { ...workspace, sandbox } +} + +/** + * The plan gate every write shares, checked right after the role check as the + * legacy routes did: its refusal is actionable and costs nothing. + */ +async function requireSandboxPlan(workspaceId: string): Promise { + if (!(await hasWorkspaceSandboxAccess(workspaceId))) { + throw new ForbiddenOperationError('WORKSPACE_PLAN_CAPABILITY_REQUIRED', MAX_PLAN_REQUIRED) + } +} + +/** + * The write budget, handed to the manager as its admission hook so it is spent + * only once the spec has validated and the name is free — a refused line or a + * collision builds nothing and must not drain what a real save needs. + * + * Charged whatever the install strategy. A prebuilt image costs provider + * compute; under a runtime-install provider every saved spec is installed again + * on the next execution, and every save invalidates resolution. It is also the + * only per-workspace admission the internal routes have, which is why neither + * a runtime provider nor an empty spec is exempt. + */ +function spendBuildBudget(workspaceId: string) { + return { admit: () => assertSandboxBuildBudget(workspaceId) } +} + +const authorizationOptions = { delegation: sandboxDelegationPolicy } + +export interface ListWorkspaceSandboxesInput { + workspaceId: string + search?: string + sortBy?: SandboxSortBy + sortOrder?: ListSortOrder + /** Absent for the whole set in one read; the public API pages. */ + limit?: number + cursorKeys?: CursorKey[] +} + +export const listWorkspaceSandboxesUseCase = defineAuthorizedWorkspaceUseCase({ + operation: sandboxOperations.list, + resolveContext: ({ input }: { input: ListWorkspaceSandboxesInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ input, context }) { + const [page, entitled] = await Promise.all([ + listWorkspaceSandboxesPage({ ...input, workspaceId: context.workspaceId }), + hasWorkspaceSandboxAccess(context.workspaceId), + ]) + return { + sandboxes: page.data, + nextCursorKeys: page.nextCursorKeys, + strategy: currentSandboxStrategy(), + /** False below the Max tier: the list still renders, but every write is refused. */ + entitled, + sortBy: input.sortBy ?? 'name', + sortOrder: input.sortOrder ?? 'asc', + } + }, +}) + +export interface GetWorkspaceSandboxInput { + workspaceId: string + sandboxId: string +} + +export const getWorkspaceSandboxUseCase = defineAuthorizedWorkspaceUseCase({ + operation: sandboxOperations.read, + resolveContext: ({ input }: { input: GetWorkspaceSandboxInput }) => + resolveSandboxContext(input.workspaceId, input.sandboxId), + authorizationOptions, + async execute({ context }) { + return { sandbox: context.sandbox } + }, +}) + +export interface CreateWorkspaceSandboxInput { + workspaceId: string + name: string + language: SandboxLanguage + dependencies: string[] + cliTools?: SandboxCliToolId[] + systemPackages?: string[] + source?: SandboxWriteSource +} + +export const createWorkspaceSandboxUseCase = defineAuthorizedWorkspaceUseCase({ + operation: sandboxOperations.create, + resolveContext: ({ input }: { input: CreateWorkspaceSandboxInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + await requireSandboxPlan(context.workspaceId) + const sandbox = await createWorkspaceSandbox( + context.workspaceId, + requirePrincipalSubjectUserId(principal), + { + name: input.name, + language: input.language, + dependencies: input.dependencies, + cliTools: input.cliTools ?? [], + systemPackages: input.systemPackages ?? [], + }, + spendBuildBudget(context.workspaceId) + ) + return { sandbox } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.SANDBOX_CREATED, + resourceType: AuditResourceType.SANDBOX, + resourceId: result.sandbox.id, + resourceName: result.sandbox.name, + description: `Created sandbox "${result.sandbox.name}"`, + metadata: { source: input.source, language: result.sandbox.language }, + }), +}) + +export interface UpdateWorkspaceSandboxInput { + workspaceId: string + sandboxId: string + name?: string + language?: SandboxLanguage + dependencies?: string[] + cliTools?: SandboxCliToolId[] + systemPackages?: string[] + source?: SandboxWriteSource +} + +export const updateWorkspaceSandboxUseCase = defineAuthorizedWorkspaceUseCase({ + operation: sandboxOperations.update, + resolveContext: ({ input }: { input: UpdateWorkspaceSandboxInput }) => + resolveSandboxContext(input.workspaceId, input.sandboxId), + authorizationOptions, + async execute({ input, context }) { + await requireSandboxPlan(context.workspaceId) + const sandbox = await updateWorkspaceSandbox( + context.workspaceId, + context.sandbox.id, + { + name: input.name, + language: input.language, + dependencies: input.dependencies, + cliTools: input.cliTools, + systemPackages: input.systemPackages, + }, + spendBuildBudget(context.workspaceId) + ) + return { sandbox } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.SANDBOX_UPDATED, + resourceType: AuditResourceType.SANDBOX, + resourceId: result.sandbox.id, + resourceName: result.sandbox.name, + description: `Updated sandbox "${result.sandbox.name}"`, + metadata: { source: input.source, language: result.sandbox.language }, + }), +}) + +export interface DeleteWorkspaceSandboxInput { + workspaceId: string + sandboxId: string + source?: SandboxWriteSource +} + +export const deleteWorkspaceSandboxUseCase = defineAuthorizedWorkspaceUseCase({ + operation: sandboxOperations.delete, + resolveContext: ({ input }: { input: DeleteWorkspaceSandboxInput }) => + resolveSandboxContext(input.workspaceId, input.sandboxId), + authorizationOptions, + /** + * No budget: deleting builds nothing, and a workspace that spent its budget + * on saves must still be able to clean up. + */ + async execute({ context }) { + await requireSandboxPlan(context.workspaceId) + await deleteWorkspaceSandbox(context.workspaceId, context.sandbox.id) + return { sandbox: context.sandbox } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.SANDBOX_DELETED, + resourceType: AuditResourceType.SANDBOX, + resourceId: result.sandbox.id, + resourceName: result.sandbox.name, + description: `Deleted sandbox "${result.sandbox.name}"`, + metadata: { source: input.source, language: result.sandbox.language }, + }), +}) diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.ts index 0aff805b423..08504768d7a 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.ts @@ -8,7 +8,7 @@ import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { ForbiddenOperationError, principalAuditSource } from '@/lib/core/application' import { getBlockVisibility } from '@/lib/core/config/block-visibility' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/entitlement' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index eb6fcc5e8f4..68569697fa8 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -180,6 +180,11 @@ export const AuditAction = { PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added', PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed', + // Sandboxes + SANDBOX_CREATED: 'sandbox.created', + SANDBOX_UPDATED: 'sandbox.updated', + SANDBOX_DELETED: 'sandbox.deleted', + // Skills SKILL_CREATED: 'skill.created', SKILL_UPDATED: 'skill.updated', @@ -260,6 +265,7 @@ export const AuditResourceType = { ORGANIZATION: 'organization', PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', + SANDBOX: 'sandbox', SCHEDULE: 'schedule', /** Not a stored resource: the workspace's secrets, as the thing put at risk. */ SECRET_PROVENANCE: 'secret_provenance', diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 277f1daf873..b3ddd963511 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -329,6 +329,9 @@ export const CLI_CONTRACT: CliContract = { confirm: 'This revokes the explicit skill editor grant for the selected email.', }, deleteCustomTool: { confirm: 'This deletes the custom tool.' }, + deleteSandbox: { + confirm: 'This deletes the sandbox; Function blocks that select it fail until re-pointed.', + }, deleteMcpServer: { confirm: 'This removes the MCP server and the tools it provides.', }, @@ -694,6 +697,25 @@ export const CLI_CONTRACT: CliContract = { importWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, createCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, + // A dependency set is typed one specifier at a time or pasted from a + // requirements file, so each list takes space-separated values or `@path` + // with one entry per line rather than a JSON array. The package lists are + // manifests: a requirements file carries blank lines and `#` comments, which + // the API ignores, so the reader drops them instead of refusing the file. + createSandbox: { + flags: { + dependencies: { list: true, manifest: true }, + cliTools: { list: true }, + systemPackages: { list: true, manifest: true }, + }, + }, + updateSandbox: { + flags: { + dependencies: { list: true, manifest: true }, + cliTools: { list: true }, + systemPackages: { list: true, manifest: true }, + }, + }, // ─── Output columns for list commands ───────────────────────────────────── listTables: { @@ -867,6 +889,17 @@ export const CLI_CONTRACT: CliContract = { { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], }, + listSandboxes: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'language' }, + // `builtAt` is null under a runtime-install deployment, so the build + // state and the last edit are what every row can show. + { header: 'status', path: 'buildStatus' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, listCredentials: { columns: [ { header: 'id' }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 901aa87a107..9ce3f14bf0d 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -57,6 +57,18 @@ export interface FlagSpec { * invisible to any type-driven generator. */ list?: boolean + /** + * The list's natural source is a manifest file, so `@path` / `@-` skip blank + * lines and `#` comments instead of refusing them. + * + * The shared reader treats a blank line as a typo, which is right for an id + * list. A dependency list is pasted from a requirements file or a lockfile, + * where blank lines and comments are how people structure it, and the API + * already ignores both — the terminal was the only surface that refused + * them. Inline argv values are untouched: an empty argument is still an + * error, and a literal `#` value can still be passed. + */ + manifest?: true /** Take a JSON string. Implied for object/array/unknown fields. */ json?: boolean /** diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 5425be0ecf8..5e93eedf761 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1819,6 +1819,88 @@ export type CreateMcpServerResponse = { data: CreateMcpServerResponseRef0 } +/** `POST /api/v2/sandboxes` */ +export type CreateSandboxQuery = Record + +export type CreateSandboxBody = { + workspaceId: string + name: string + language: 'javascript' | 'python' + dependencies?: Array + cliTools?: Array< + | 'google-cloud-cli@577.0.0-r1' + | 'aws-cli@2.36.15-r1' + | 'azure-cli@2.89.0-r1' + | 'doctl@1.166.0-r1' + | 'github-cli@2.97.0-r1' + | 'gitlab-cli@1.111.0-r1' + | 'kubectl@1.36.3-r1' + | 'helm@4.2.3-r1' + | 'kustomize@5.8.1-r1' + | 'argocd@3.4.6-r1' + | 'terraform@1.15.8-r1' + | 'pulumi@3.255.0-r1' + | 'supabase-cli@2.111.0-r1' + | 'firebase-cli@15.25.1-r1' + | 'flyctl@0.4.78-r1' + | 'railway-cli@5.30.4-r1' + | 'stripe-cli@1.45.0-r1' + | 'duckdb@1.5.5-r1' + | 'rclone@1.75.0-r1' + | 'restic@0.19.1-r1' + | 'minio-mc@RELEASE.2025-08-13T08-35-41Z-r1' + | 'mongosh@2.9.2-r1' + | 'sops@3.13.3-r1' + | 'age@1.3.1-r1' + > + systemPackages?: Array +} + +type CreateSandboxResponseRef0 = { + id: string + name: string + language: 'javascript' | 'python' + dependencies: Array + cliTools: Array< + | 'google-cloud-cli@577.0.0-r1' + | 'aws-cli@2.36.15-r1' + | 'azure-cli@2.89.0-r1' + | 'doctl@1.166.0-r1' + | 'github-cli@2.97.0-r1' + | 'gitlab-cli@1.111.0-r1' + | 'kubectl@1.36.3-r1' + | 'helm@4.2.3-r1' + | 'kustomize@5.8.1-r1' + | 'argocd@3.4.6-r1' + | 'terraform@1.15.8-r1' + | 'pulumi@3.255.0-r1' + | 'supabase-cli@2.111.0-r1' + | 'firebase-cli@15.25.1-r1' + | 'flyctl@0.4.78-r1' + | 'railway-cli@5.30.4-r1' + | 'stripe-cli@1.45.0-r1' + | 'duckdb@1.5.5-r1' + | 'rclone@1.75.0-r1' + | 'restic@0.19.1-r1' + | 'minio-mc@RELEASE.2025-08-13T08-35-41Z-r1' + | 'mongosh@2.9.2-r1' + | 'sops@3.13.3-r1' + | 'age@1.3.1-r1' + > + systemPackages: Array + buildStatus: 'pending' | 'building' | 'ready' | 'failed' | null + errorCode: string | null + errorMessage: string | null + errorDetail: string | null + builtAt: string | null + createdAt: string + updatedAt: string +} + +export type CreateSandboxResponse = { + data: CreateSandboxResponseRef0 +} + /** `POST /api/v2/credentials` */ export type CreateServiceAccountCredentialQuery = Record @@ -2798,6 +2880,24 @@ export type DeleteMcpServerResponse = { data: DeleteMcpServerResponseRef0 } +/** `DELETE /api/v2/sandboxes/[sandboxId]` */ +export type DeleteSandboxParams = { + sandboxId: string +} + +export type DeleteSandboxQuery = { + workspaceId: string +} + +type DeleteSandboxResponseRef0 = { + id: string + deleted: true +} + +export type DeleteSandboxResponse = { + data: DeleteSandboxResponseRef0 +} + /** `DELETE /api/v2/secrets/[name]` */ export type DeleteSecretParams = { name: string @@ -4322,6 +4422,60 @@ export type GetRowEnrichmentResponse = { data: GetRowEnrichmentResponseRef0 | null } +/** `GET /api/v2/sandboxes/[sandboxId]` */ +export type GetSandboxParams = { + sandboxId: string +} + +export type GetSandboxQuery = { + workspaceId: string +} + +type GetSandboxResponseRef0 = { + id: string + name: string + language: 'javascript' | 'python' + dependencies: Array + cliTools: Array< + | 'google-cloud-cli@577.0.0-r1' + | 'aws-cli@2.36.15-r1' + | 'azure-cli@2.89.0-r1' + | 'doctl@1.166.0-r1' + | 'github-cli@2.97.0-r1' + | 'gitlab-cli@1.111.0-r1' + | 'kubectl@1.36.3-r1' + | 'helm@4.2.3-r1' + | 'kustomize@5.8.1-r1' + | 'argocd@3.4.6-r1' + | 'terraform@1.15.8-r1' + | 'pulumi@3.255.0-r1' + | 'supabase-cli@2.111.0-r1' + | 'firebase-cli@15.25.1-r1' + | 'flyctl@0.4.78-r1' + | 'railway-cli@5.30.4-r1' + | 'stripe-cli@1.45.0-r1' + | 'duckdb@1.5.5-r1' + | 'rclone@1.75.0-r1' + | 'restic@0.19.1-r1' + | 'minio-mc@RELEASE.2025-08-13T08-35-41Z-r1' + | 'mongosh@2.9.2-r1' + | 'sops@3.13.3-r1' + | 'age@1.3.1-r1' + > + systemPackages: Array + buildStatus: 'pending' | 'building' | 'ready' | 'failed' | null + errorCode: string | null + errorMessage: string | null + errorDetail: string | null + builtAt: string | null + createdAt: string + updatedAt: string +} + +export type GetSandboxResponse = { + data: GetSandboxResponseRef0 +} + /** `GET /api/v2/skills/[skillId]` */ export type GetSkillParams = { skillId: string @@ -5948,6 +6102,62 @@ export type ListMcpServerToolsResponse = { nextCursor: string | null } +/** `GET /api/v2/sandboxes` */ +export type ListSandboxesQuery = { + workspaceId: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListSandboxesResponseRef0 = { + id: string + name: string + language: 'javascript' | 'python' + dependencies: Array + cliTools: Array< + | 'google-cloud-cli@577.0.0-r1' + | 'aws-cli@2.36.15-r1' + | 'azure-cli@2.89.0-r1' + | 'doctl@1.166.0-r1' + | 'github-cli@2.97.0-r1' + | 'gitlab-cli@1.111.0-r1' + | 'kubectl@1.36.3-r1' + | 'helm@4.2.3-r1' + | 'kustomize@5.8.1-r1' + | 'argocd@3.4.6-r1' + | 'terraform@1.15.8-r1' + | 'pulumi@3.255.0-r1' + | 'supabase-cli@2.111.0-r1' + | 'firebase-cli@15.25.1-r1' + | 'flyctl@0.4.78-r1' + | 'railway-cli@5.30.4-r1' + | 'stripe-cli@1.45.0-r1' + | 'duckdb@1.5.5-r1' + | 'rclone@1.75.0-r1' + | 'restic@0.19.1-r1' + | 'minio-mc@RELEASE.2025-08-13T08-35-41Z-r1' + | 'mongosh@2.9.2-r1' + | 'sops@3.13.3-r1' + | 'age@1.3.1-r1' + > + systemPackages: Array + buildStatus: 'pending' | 'building' | 'ready' | 'failed' | null + errorCode: string | null + errorMessage: string | null + errorDetail: string | null + builtAt: string | null + createdAt: string + updatedAt: string +} + +export type ListSandboxesResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/secrets` */ export type ListSecretsQuery = { workspaceId: string @@ -8482,6 +8692,92 @@ export type UpdateRowsByFilterResponse = { data: UpdateRowsByFilterResponseRef0 } +/** `PATCH /api/v2/sandboxes/[sandboxId]` */ +export type UpdateSandboxParams = { + sandboxId: string +} + +export type UpdateSandboxQuery = Record + +export type UpdateSandboxBody = { + workspaceId: string + name?: string + language?: 'javascript' | 'python' + dependencies?: Array + cliTools?: Array< + | 'google-cloud-cli@577.0.0-r1' + | 'aws-cli@2.36.15-r1' + | 'azure-cli@2.89.0-r1' + | 'doctl@1.166.0-r1' + | 'github-cli@2.97.0-r1' + | 'gitlab-cli@1.111.0-r1' + | 'kubectl@1.36.3-r1' + | 'helm@4.2.3-r1' + | 'kustomize@5.8.1-r1' + | 'argocd@3.4.6-r1' + | 'terraform@1.15.8-r1' + | 'pulumi@3.255.0-r1' + | 'supabase-cli@2.111.0-r1' + | 'firebase-cli@15.25.1-r1' + | 'flyctl@0.4.78-r1' + | 'railway-cli@5.30.4-r1' + | 'stripe-cli@1.45.0-r1' + | 'duckdb@1.5.5-r1' + | 'rclone@1.75.0-r1' + | 'restic@0.19.1-r1' + | 'minio-mc@RELEASE.2025-08-13T08-35-41Z-r1' + | 'mongosh@2.9.2-r1' + | 'sops@3.13.3-r1' + | 'age@1.3.1-r1' + > + systemPackages?: Array +} + +type UpdateSandboxResponseRef0 = { + id: string + name: string + language: 'javascript' | 'python' + dependencies: Array + cliTools: Array< + | 'google-cloud-cli@577.0.0-r1' + | 'aws-cli@2.36.15-r1' + | 'azure-cli@2.89.0-r1' + | 'doctl@1.166.0-r1' + | 'github-cli@2.97.0-r1' + | 'gitlab-cli@1.111.0-r1' + | 'kubectl@1.36.3-r1' + | 'helm@4.2.3-r1' + | 'kustomize@5.8.1-r1' + | 'argocd@3.4.6-r1' + | 'terraform@1.15.8-r1' + | 'pulumi@3.255.0-r1' + | 'supabase-cli@2.111.0-r1' + | 'firebase-cli@15.25.1-r1' + | 'flyctl@0.4.78-r1' + | 'railway-cli@5.30.4-r1' + | 'stripe-cli@1.45.0-r1' + | 'duckdb@1.5.5-r1' + | 'rclone@1.75.0-r1' + | 'restic@0.19.1-r1' + | 'minio-mc@RELEASE.2025-08-13T08-35-41Z-r1' + | 'mongosh@2.9.2-r1' + | 'sops@3.13.3-r1' + | 'age@1.3.1-r1' + > + systemPackages: Array + buildStatus: 'pending' | 'building' | 'ready' | 'failed' | null + errorCode: string | null + errorMessage: string | null + errorDetail: string | null + builtAt: string | null + createdAt: string + updatedAt: string +} + +export type UpdateSandboxResponse = { + data: UpdateSandboxResponseRef0 +} + /** `PATCH /api/v2/skills/[skillId]` */ export type UpdateSkillParams = { skillId: string @@ -10060,6 +10356,47 @@ export const V2_OPERATIONS = { }, }, }, + createSandbox: { + method: 'POST', + path: '/api/v2/sandboxes', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Sandbox', + personalKeyOnly: true, + body: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace in which to create the sandbox.', + }, + name: { + kind: 'string', + required: true, + describe: 'Display name, unique within the workspace; 1 to 64 characters.', + }, + language: { + kind: 'enum', + required: true, + values: ['javascript', 'python'] as const, + describe: 'Dependency ecosystem: `javascript` installs from npm, `python` from PyPI.', + }, + dependencies: { + kind: 'array', + default: [], + describe: 'Package specifiers installed into the sandbox, one per entry.', + }, + cliTools: { + kind: 'array', + default: [], + describe: 'Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates.', + }, + systemPackages: { + kind: 'array', + default: [], + describe: 'Debian packages installed into the sandbox, one per entry.', + }, + }, + }, createServiceAccountCredential: { method: 'POST', path: '/api/v2/credentials', @@ -10585,6 +10922,18 @@ export const V2_OPERATIONS = { }, }, }, + deleteSandbox: { + method: 'DELETE', + path: '/api/v2/sandboxes/[sandboxId]', + pathParams: ['sandboxId'] as const, + pathParamDocs: { sandboxId: 'Unique sandbox identifier.' }, + responseMode: 'json', + summary: 'Delete Sandbox', + personalKeyOnly: true, + query: { + workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the sandbox.' }, + }, + }, deleteSecret: { method: 'DELETE', path: '/api/v2/secrets/[name]', @@ -11313,6 +11662,17 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the table.' }, }, }, + getSandbox: { + method: 'GET', + path: '/api/v2/sandboxes/[sandboxId]', + pathParams: ['sandboxId'] as const, + pathParamDocs: { sandboxId: 'Unique sandbox identifier.' }, + responseMode: 'json', + summary: 'Get Sandbox', + query: { + workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the sandbox.' }, + }, + }, getSkill: { method: 'GET', path: '/api/v2/skills/[skillId]', @@ -12544,6 +12904,44 @@ export const V2_OPERATIONS = { }, }, }, + listSandboxes: { + method: 'GET', + path: '/api/v2/sandboxes', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Sandboxes', + query: { + workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the sandbox.' }, + search: { + kind: 'string', + describe: 'Case-insensitive substring match against the sandbox name.', + }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + describe: + 'Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'asc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum sandboxes to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + }, + }, listSecrets: { method: 'GET', path: '/api/v2/secrets', @@ -14177,6 +14575,40 @@ export const V2_OPERATIONS = { limit: { kind: 'integer', describe: 'Maximum matching rows to update.' }, }, }, + updateSandbox: { + method: 'PATCH', + path: '/api/v2/sandboxes/[sandboxId]', + pathParams: ['sandboxId'] as const, + pathParamDocs: { sandboxId: 'Unique sandbox identifier.' }, + responseMode: 'json', + summary: 'Update Sandbox', + personalKeyOnly: true, + body: { + workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the sandbox.' }, + name: { + kind: 'string', + describe: 'New display name, unique within the workspace; 1 to 64 characters.', + }, + language: { + kind: 'enum', + values: ['javascript', 'python'] as const, + describe: + 'Replacement dependency ecosystem. The whole spec is revalidated against it, so a Python dependency list does not survive a switch to JavaScript.', + }, + dependencies: { + kind: 'array', + describe: 'Replacement package list; replaces the whole list.', + }, + cliTools: { + kind: 'array', + describe: 'Replacement managed CLI list; replaces the whole list.', + }, + systemPackages: { + kind: 'array', + describe: 'Replacement Debian package list; replaces the whole list.', + }, + }, + }, updateSkill: { method: 'PATCH', path: '/api/v2/skills/[skillId]', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 9865d67a8d6..a40341e5bd1 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -995,6 +995,7 @@ describe('destructive operations are gated', () => { 'createKnowledgeFolder', 'createKnowledgeTag', 'createMcpServer', + 'createSandbox', 'createServiceAccountCredential', 'createSkill', 'createTable', @@ -1055,6 +1056,7 @@ describe('destructive operations are gated', () => { 'updateKnowledgeDocument', 'updateKnowledgeTag', 'updateMcpServer', + 'updateSandbox', 'updateSkill', 'updateTable', 'updateTableColumn', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 41745a29bb1..82c992b7ec4 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -196,6 +196,7 @@ describe('commands parsed through commander', () => { knowledge: 'kb', logs: 'log', 'mcp-servers': 'mcp-server', + sandboxes: 'sandbox', secrets: 'secret', skills: 'skill', tables: 'table', diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 3d80174fbe6..92f7e961bdb 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -24,6 +24,7 @@ const GROUP_ALIASES: Readonly> = { knowledge: 'kb', logs: 'log', 'mcp-servers': 'mcp-server', + sandboxes: 'sandbox', secrets: 'secret', skills: 'skill', tables: 'table', diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index a967920b83d..c05b9d8bfa0 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -167,7 +167,9 @@ function addFieldOption( const literalNull = slot === 'body' && !takesList && !wantsJson const describe = `${documented}${ takesList - ? ' (space-separated, or @path / @- with one value per line; @@value for a literal leading @)' + ? flag.manifest + ? ' (space-separated, or @path / @- with one value per line; in a file, blank lines and # comments are ignored, while inline values are sent as typed and may not be empty; @@value for a literal leading @)' + : ' (space-separated, or @path / @- with one value per line; @@value for a literal leading @)' : wantsJson ? ' (JSON, or @path / @- to read a file or stdin)' : '' diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 622835d1048..69206c97fc8 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -376,6 +376,44 @@ describe('repeated flags encode per the field kind, not uniformly', () => { ) rmSync(path) }) + + /** + * A requirements file is passed as it is on disk: its blank lines and `#` + * comments are structure, not values, and the API ignores both. Only a + * manifest list reads a file this way; an id list keeps refusing a blank + * line, because there it is a typo. + */ + it('skips blank lines and # comments in a manifest list file', () => { + const path = join(tmpdir(), 'sim-cli-requirements.txt') + writeFileSync( + path, + '# pinned for the ETL job\npandas==2.2.2\n\n # transitive, kept explicit\nrequests\n' + ) + expect( + coerce(`@${path}`, { kind: 'array' }, { list: true, manifest: true }, 'dependencies') + ).toEqual(['pandas==2.2.2', 'requests']) + rmSync(path) + }) + + it('refuses a manifest file that carries only comments', () => { + const path = join(tmpdir(), 'sim-cli-requirements-empty.txt') + writeFileSync(path, '# nothing yet\n\n') + expect(() => + coerce(`@${path}`, { kind: 'array' }, { list: true, manifest: true }, 'dependencies') + ).toThrow(/contains no values/) + rmSync(path) + }) + + it('keeps an inline # value on a manifest list', () => { + expect( + coerce( + ['# not a comment here'], + { kind: 'array' }, + { list: true, manifest: true }, + 'dependencies' + ) + ).toEqual(['# not a comment here']) + }) }) describe('contract-provided choices', () => { diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 04dba16d7f3..7fcb32b1e00 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -224,8 +224,18 @@ export function readArgumentSource(raw: string, flagName: string): { text: strin } } -/** Reads a primitive list from argv or a newline-delimited file. */ -function readListValues(raw: unknown, flagName: string): string[] { +/** A manifest line that carries no value: blank, or a `#` comment. */ +function isManifestNoise(line: string): boolean { + const trimmed = line.trim() + return trimmed === '' || trimmed.startsWith('#') +} + +/** + * Reads a primitive list from argv or a newline-delimited file. A `manifest` + * list drops blank and `#` comment lines read from a file, so a requirements + * file can be passed as it is on disk. + */ +function readListValues(raw: unknown, flagName: string, manifest = false): string[] { const arguments_ = Array.isArray(raw) ? raw : [raw] const values = arguments_.flatMap((argument) => { if (typeof argument !== 'string') { @@ -237,11 +247,12 @@ function readListValues(raw: unknown, flagName: string): string[] { const source = readArgumentSource(argument, flagName) const lines = source.text.split(/\r?\n/) if (lines.at(-1) === '') lines.pop() - if (lines.length === 0) { + const kept = manifest ? lines.filter((line) => !isManifestNoise(line)) : lines + if (kept.length === 0) { throw new SimApiError(`--${flagName}${source.from} contains no values`, 0) } - return lines.map((line, index) => { + return kept.map((line, index) => { const value = line.trim() if (!value) { throw new SimApiError( @@ -365,7 +376,7 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: * or failed validation outright. */ if (flag.list) { - const values = readListValues(raw, flagName).map((value) => + const values = readListValues(raw, flagName, flag.manifest === true).map((value) => flag.folderPath ? encodeFolderPath(value) : value ) // Encoding first is also what keeps the comma-joined form unambiguous: a diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 855793e8a73..f2c6362efb0 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -141,6 +141,9 @@ export const auditMock = { SCHEDULE_CREATED: 'schedule.created', SCHEDULE_UPDATED: 'schedule.updated', SCHEDULE_DELETED: 'schedule.deleted', + SANDBOX_CREATED: 'sandbox.created', + SANDBOX_UPDATED: 'sandbox.updated', + SANDBOX_DELETED: 'sandbox.deleted', SKILL_CREATED: 'skill.created', SKILL_UPDATED: 'skill.updated', SKILL_DELETED: 'skill.deleted', @@ -216,6 +219,7 @@ export const auditMock = { ORGANIZATION: 'organization', PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', + SANDBOX: 'sandbox', SCHEDULE: 'schedule', SECRET_PROVENANCE: 'secret_provenance', SKILL: 'skill', diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 2661cd5de5f..988bad712c9 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -40,7 +40,7 @@ const EXPECTED_OPERATION_COUNTS = new Map([ ['apps/docs/openapi-v2-tables.json', 53], ['apps/docs/openapi-v2-knowledge.json', 44], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 46], + ['apps/docs/openapi-v2-resources.json', 51], ]) const generatedDocuments = new Map<(typeof DOCUMENTS)[number], JsonObject>() @@ -183,7 +183,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(215) + expect(totalOperations).toBe(220) }) it('documents mixed workflow execution and resume responses', () => {