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', () => {