Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
77fd840
docs(api-keys): add rollback guidance to the api-key-read-only flag
waleedlatif1 Nov 10, 2026
27066b8
docs(api): say v2 workflow execution needs a full-access API key
waleedlatif1 Nov 10, 2026
6ff0179
chore(api): regenerate MCP operations for the execute scope wording
waleedlatif1 Nov 10, 2026
b9dc603
fix(hitl): stop duplicating input fields across listed pause points
waleedlatif1 Nov 10, 2026
07d5c25
fix(api): refuse a personal v2 key with a missing owner as invalid, n…
waleedlatif1 Nov 10, 2026
1a8ef09
fix(credentials): refuse a keyless subject another live credential al…
waleedlatif1 Nov 10, 2026
88a8a85
refactor(tools): detect the saved-credential picker with isSavedCrede…
waleedlatif1 Nov 10, 2026
d5d8547
fix(runtime-secrets): fetch the boot secret through the configured proxy
waleedlatif1 Nov 10, 2026
638b457
fix(models): mark NVIDIA Nemotron 3 Super retired and move the NVIDIA…
waleedlatif1 Nov 10, 2026
b96b952
fix(credentials): lock keyless subject checks through the advisory-lo…
waleedlatif1 Nov 10, 2026
76e8618
fix(network): match NO_PROXY entries like curl, including bare IPv6 a…
waleedlatif1 Nov 10, 2026
aefa2e4
docs(network): note the boot secret fetch follows Bun's NO_PROXY rules
waleedlatif1 Nov 10, 2026
297f796
fix(credentials): decide subject reuse under the lock on every keyles…
waleedlatif1 Nov 11, 2026
f788124
fix(runtime-secrets): pick the boot fetch's proxy with the app's rules
waleedlatif1 Nov 11, 2026
87fc208
docs(api): state the credential and role a manual v2 run requires
waleedlatif1 Nov 11, 2026
f1d0b0f
Merge remote-tracking branch 'origin/staging' into fix/release-audit-…
waleedlatif1 Nov 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/docs/content/docs/platform/self-hosting/networking.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ A request through the SSRF guard keeps its checks when it is proxied. Before eac

Set `NO_PROXY` for every destination that is not on the public internet, not just model endpoints. The app reaches the realtime server (`SOCKET_SERVER_URL`), the Presidio PII service (`PII_URL`), and itself (`INTERNAL_API_BASE_URL`) over the same default `fetch`, alongside self-hosted Ollama, LiteLLM, and vLLM — so a proxy that cannot reach your internal network breaks live updates and PII redaction, not only inference. AWS clients and OTLP export follow the same rule, so also add an internal S3-compatible store such as MinIO, AWS VPC endpoints, and an in-cluster OpenTelemetry collector.

For the SSRF guard, AWS clients (including the boot-time Secrets Manager fetch), and OTLP export, Sim matches `NO_PROXY` entries the way curl does: `example.com`, `.example.com`, and `*.example.com` each cover the domain and all its subdomains; `host:port` and `[::1]:port` cover one port; IPv6 entries work bare (`::1`) or bracketed; and CIDR ranges such as `10.0.0.0/8` cover IP-literal destinations only, not hostnames that resolve into the range. Bun's default `fetch`, which carries internal service URLs such as the realtime server and PII service, applies Bun's own `NO_PROXY` rules, which may not honor CIDR or bare IPv6 entries, so list those destinations by hostname.

Set it in the application environment, not your shell — under `app.env` on Helm, or the service's `environment:` on Compose. On Helm the suffixes alone are not enough: the chart wires `SOCKET_SERVER_URL`, `PII_URL`, and `OLLAMA_URL` to bare Service names, which no domain suffix matches. Add those names too — `helm template` prints the rendered ones, and the prefix is the release name unless it already contains `sim`, in which case it is the release name alone:

```yaml
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -3958,7 +3958,7 @@
"post": {
"operationId": "executeWorkflowV2",
"summary": "Execute workflow",
"description": "Run the active deployment, set `run: { source: \"deployment\", version }` to run an earlier deployment version, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and an API key or OAuth access token; an unknown version returns `404`. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.",
"description": "Run the active deployment, set `run: { source: \"deployment\", version }` to run an earlier deployment version, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or a full-access API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and a full-access API key or OAuth `api:write` scope; an unknown version returns `404`. Manual runs require a full-access personal API key or OAuth `api:write` scope, and workspace write access; workspace API keys are refused, and manual runs cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.",
"x-sim-operation": "workflows.execute",
"x-oauth-scope": "api:write",
"tags": ["Workflows"],
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/v2/openapi/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,7 +1282,7 @@ const declaredRoutes = [
applicationOperation: workflowOperations.execute,
operationId: 'executeWorkflowV2',
summary: 'Execute workflow',
description: `Run the active deployment, set \`run: { source: "deployment", version }\` to run an earlier deployment version, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and an API key or OAuth access token; an unknown version returns \`404\`. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. With \`waitTimeoutSeconds\`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: \`202\` on a JSON response, or a closing \`detached\` frame on an NDJSON stream, which keeps its \`200\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`,
description: `Run the active deployment, set \`run: { source: "deployment", version }\` to run an earlier deployment version, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or a full-access API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and a full-access API key or OAuth \`api:write\` scope; an unknown version returns \`404\`. Manual runs require a full-access personal API key or OAuth \`api:write\` scope, and workspace write access; workspace API keys are refused, and manual runs cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. With \`waitTimeoutSeconds\`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: \`202\` on a JSON response, or a closing \`detached\` frame on an NDJSON stream, which keeps its \`200\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`,
errors: [
'BadRequest',
'Unauthorized',
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/mcp/generated/v2-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1252,7 +1252,7 @@ export const V2_MCP_OPERATIONS = {
contract: v2ExecuteWorkflowContract,
summary: 'Execute workflow',
description:
'Run the active deployment, set `run: { source: "deployment", version }` to run an earlier deployment version, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and an API key or OAuth access token; an unknown version returns `404`. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.',
'Run the active deployment, set `run: { source: "deployment", version }` to run an earlier deployment version, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or a full-access API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and a full-access API key or OAuth `api:write` scope; an unknown version returns `404`. Manual runs require a full-access personal API key or OAuth `api:write` scope, and workspace write access; workspace API keys are refused, and manual runs cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.',
handler: () =>
import('@/app/api/v2/workflows/[workflowId]/execute/route').then((route) => route.POST),
},
Expand Down
22 changes: 22 additions & 0 deletions apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,28 @@ describe('v2 API key authentication', () => {
)
})

it('refuses a personal key whose owner row is gone as invalid, expired or not', async () => {
for (const expiresAt of [null, new Date(Date.now() - 1)]) {
queueTableRows(schemaMock.apiKey, [
{
id: 'key-orphan',
userId: 'user-gone',
workspaceId: null,
type: 'personal',
access: 'write',
expiresAt,
userBanned: null,
userSuspendedAt: null,
},
])
const refusal = await authenticateV2ApiKey({ apiKey: 'orphan', bearer: null }).catch(
(error: unknown) => error
)
expect(refusal).toBeInstanceOf(V2ApiKeyUnauthenticatedError)
expect((refusal as V2ApiKeyUnauthenticatedError).message).toBe('Invalid API key')
}
})

it('propagates auth-store failures instead of converting them to invalid credentials', async () => {
const failure = new Error('database unavailable')
dbChainMockFns.limit.mockRejectedValueOnce(failure)
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/lib/api/server/routes/v2-api-key-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow {
if (!row) throw new V2ApiKeyUnauthenticatedError()
if (row.type === 'personal' && row.workspaceId === null) {
if (row.userBanned === null) {
throw new Error(`Personal API key ${row.id} is missing its credential owner`)
logger.error('Personal API key is missing its credential owner', { keyId: row.id })
throw new V2ApiKeyUnauthenticatedError()
}
if (row.userBanned) throw new V2ApiKeyUnauthenticatedError()
/**
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/core/config/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const FEATURE_FLAGS = {
},
'api-key-read-only': {
description:
'Allow creating read-only API keys. Turn on only after every pod runs a release that enforces key access, since older pods treat a read-only key as full access. Enforcement of existing read-only keys never depends on this flag. Global; disabled by default.',
'Allow creating read-only API keys. Turn on only after every pod runs a release that enforces key access, since older pods treat a read-only key as full access. Before rolling back below the release that added read-only keys, turn this off first: that older code also serves every existing read-only key as full access. Enforcement of existing read-only keys never depends on this flag. Global; disabled by default.',
fallback: 'API_KEY_READ_ONLY',
},
'mothership-model-selector': {
Expand Down
62 changes: 61 additions & 1 deletion apps/sim/lib/core/network/environment-proxy.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ vi.mock('@sim/security/dns', async (importOriginal) => {
}
})

import { resolveEnvironmentProxy } from '@/lib/core/network/environment-proxy.server'
import { resolveEnvironmentProxy } from '@sim/security/environment-proxy'
import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server'
import {
createPinnedFetchWithDispatcher,
Expand Down Expand Up @@ -323,6 +323,66 @@ describe('proxy selection from the environment', () => {
target: 'https://api.example.com/',
proxy: 'http://p:3128/',
},
{
case: 'a bare NO_PROXY domain also excludes its subdomains',
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: 'example.com' },
target: 'https://api.example.com/',
proxy: null,
},
{
case: 'a leading-dot NO_PROXY domain also excludes the apex',
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '.example.com' },
target: 'https://example.com/',
proxy: null,
},
{
case: 'a NO_PROXY domain does not match a longer name that merely ends with it',
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: 'example.com' },
target: 'https://notexample.com/',
proxy: 'http://p:3128/',
},
{
case: 'a bare IPv6 NO_PROXY entry excludes that address',
env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: 'localhost,::1' },
target: 'http://[::1]:3000/',
proxy: null,
},
{
case: 'an IPv6 NO_PROXY entry matches any spelling of the address',
env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: '0:0:0:0:0:0:0:1' },
target: 'http://[::1]/',
proxy: null,
},
{
case: 'a bracketed IPv6 NO_PROXY entry with a port only excludes that port',
env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: '[::1]:3000' },
target: 'http://[::1]:4000/',
proxy: 'http://p:3128/',
},
{
case: 'an IPv4 CIDR NO_PROXY entry excludes addresses inside the range',
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '10.0.0.0/8' },
target: 'https://10.20.30.40/',
proxy: null,
},
{
case: 'an IPv4 CIDR NO_PROXY entry leaves addresses outside the range proxied',
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '10.0.0.0/8' },
target: 'https://11.0.0.1/',
proxy: 'http://p:3128/',
},
{
case: 'an IPv6 CIDR NO_PROXY entry excludes addresses inside the range',
env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: 'fd00::/8' },
target: 'http://[fd12::1]/',
proxy: null,
},
{
case: 'an empty lowercase no_proxy does not hide the uppercase one',
env: { HTTPS_PROXY: 'http://p:3128', no_proxy: '', NO_PROXY: 'example.com' },
target: 'https://example.com/',
proxy: null,
},
{
case: 'NO_PROXY=* excludes every destination',
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '*' },
Expand Down
Loading
Loading