From 77fd84083610a88dac3eac721cc715f45a63b4e0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:22:34 -0800 Subject: [PATCH 01/15] docs(api-keys): add rollback guidance to the api-key-read-only flag --- apps/sim/lib/core/config/feature-flags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 42d83122109..91d55e1482d 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -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': { From 27066b83923453447ef8ee01004daf20eb7177c0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:22:35 -0800 Subject: [PATCH 02/15] docs(api): say v2 workflow execution needs a full-access API key --- apps/docs/openapi-v2-workflows.json | 2 +- apps/sim/lib/api/contracts/v2/openapi/workflows.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 8d7fc7cb356..4654bef2ae1 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -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 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`.", "x-sim-operation": "workflows.execute", "x-oauth-scope": "api:write", "tags": ["Workflows"], diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 929402de51e..cc841da6ab3 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -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 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.`, errors: [ 'BadRequest', 'Unauthorized', From 6ff01792bb27a5373066554ed36f7045dd82a783 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:38:04 -0800 Subject: [PATCH 03/15] chore(api): regenerate MCP operations for the execute scope wording --- apps/sim/lib/api/mcp/generated/v2-operations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 0b7a66f8915..4111ad8de84 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -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 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`.', handler: () => import('@/app/api/v2/workflows/[workflowId]/execute/route').then((route) => route.POST), }, From b9dc60364fecf5aebab143c5226c591bb8cecea8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:24:33 -0800 Subject: [PATCH 04/15] fix(hitl): stop duplicating input fields across listed pause points --- .../lib/workflows/executor/human-in-the-loop-manager.ts | 9 +++++---- .../workflows/executor/pause-persistence.integration.ts | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 286e91edb6f..a413db2d390 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -3624,12 +3624,13 @@ export class PauseResumeManager { entry.contextId === contextId && (entry.status === 'claimed' || entry.status === 'pending') ) - // The selected pause point's full `response.data` and `display` are already - // returned via `pausePoint` below; strip both from `execution.pausePoints` so a - // large HITL display payload isn't duplicated in full within the same response. + // The selected pause point's full `response.data`, `display`, and `inputFields` + // are already returned via `pausePoint` below; strip them from + // `execution.pausePoints` so a large HITL payload isn't duplicated in full + // within the same response. const execution: PausedExecutionDetail = { ...detail, - pausePoints: detail.pausePoints.map(({ display, ...point }) => + pausePoints: detail.pausePoints.map(({ display, inputFields, ...point }) => point.response ? { ...point, response: { ...point.response, data: undefined } } : point ), } diff --git a/apps/sim/lib/workflows/executor/pause-persistence.integration.ts b/apps/sim/lib/workflows/executor/pause-persistence.integration.ts index df03f717ae8..352589ee73b 100644 --- a/apps/sim/lib/workflows/executor/pause-persistence.integration.ts +++ b/apps/sim/lib/workflows/executor/pause-persistence.integration.ts @@ -445,7 +445,7 @@ describe('readPausedRunPoints', () => { } }) - it('returns the selected point display once, not again in every listed point', async () => { + it('returns the selected point display and input fields once, not again in every listed point', async () => { const registry = registryWithResolvedSecret() const executionId = await publishPausedRun([ humanBranchPoint(0, registry), @@ -461,9 +461,11 @@ describe('readPausedRunPoints', () => { expect(detail?.pausePoint.display).toEqual({ responseStructure: [{ name: 'summary', type: 'string', value: 'Token is {{API_KEY}}' }], }) + expect(detail?.pausePoint.inputFields).toHaveLength(1) expect(detail?.execution.pausePoints).toHaveLength(2) for (const point of detail?.execution.pausePoints ?? []) { expect(point).not.toHaveProperty('display') + expect(point).not.toHaveProperty('inputFields') } }) From 07d5c25214dd1c4e2f035f4e5072733d9588c727 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:25:12 -0800 Subject: [PATCH 05/15] fix(api): refuse a personal v2 key with a missing owner as invalid, not a 500 --- .../api/server/routes/v2-api-key-auth.test.ts | 22 +++++++++++++++++++ .../lib/api/server/routes/v2-api-key-auth.ts | 3 ++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts index 6c9c46daaa2..8308eee3ceb 100644 --- a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts @@ -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) diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts index bb636387d10..f4ac64492d0 100644 --- a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts @@ -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() /** From 1a8ef0921ecb34b101cbc06aa71023ba857dce41 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:35:52 -0800 Subject: [PATCH 06/15] fix(credentials): refuse a keyless subject another live credential already holds A keyless subject proof stays valid for 24 hours and is not bound to one credential, so a member removed from a keyless AWS or Databricks credential could save a second credential with the same subject and proof and keep assuming the customer's role. Creating or reconnecting a credential onto a subject it did not already hold now refuses (409 conflict) when another non-revoked credential of the same owner holds it. The subject lives only in the encrypted secret, so the check decrypts the owner's keyless credentials inside the write transaction, under a per-subject advisory lock so concurrent saves serialize. A reconnect keeping its stored subject binds nothing new and skips the check. --- ...load-identity-subject-reuse.integration.ts | 194 ++++++++++++++++++ .../credentials/aws-service-account.server.ts | 15 +- .../databricks-service-account.server.ts | 16 +- .../orchestration/credential-create.ts | 19 ++ .../lib/credentials/orchestration/index.ts | 27 ++- .../lib/credentials/service-account-secret.ts | 7 + .../credentials/workload-identity/subject.ts | 62 ++++++ 7 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts diff --git a/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts b/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts new file mode 100644 index 00000000000..3a9c9462720 --- /dev/null +++ b/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts @@ -0,0 +1,194 @@ +/** + * A keyless trust setup (subject plus its day-long proof) binds to one live + * credential: saving it onto a second credential is refused, even concurrently, + * while the credential that holds it can still be reconnected. + */ + +import { db } from '@sim/db' +import { credential, credentialMember, permissions, user, workspace } from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { jsonResponse } from '@sim/testing/helpers/http' +import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/credentials/workload-identity/issuer', async (importOriginal) => ({ + ...(await importOriginal()), + mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `token-for:${subject}`, +})) + +import { updateCredentialRecord } from '@/lib/credentials/orchestration' +import { createCredentialRecord } from '@/lib/credentials/orchestration/credential-create' +import { issueWorkloadIdentitySubject } from '@/lib/credentials/workload-identity/subject' +import { DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' + +const HOST = 'dbc-1234abcd-5678.cloud.databricks.com' +const CLIENT_ID = '00000000-0000-0000-0000-000000000000' +const userId = generateId() +const workspaceId = generateId() +const owner = { workspaceId } +const originalFetch = globalThis.fetch + +/** Subjects the fixture service principal's federation policy pins. */ +const pinnedSubjects = new Set() + +function grant() { + const issued = issueWorkloadIdentitySubject('databricks', owner, userId) + pinnedSubjects.add(issued.subject) + return issued +} + +function keyless(subject?: { subject: string; subjectProof: string }) { + return JSON.stringify({ + authType: 'oidc_federation', + host: HOST, + clientId: CLIENT_ID, + ...(subject ? { subject: subject.subject, subjectProof: subject.subjectProof } : {}), + }) +} + +function create(displayName: string, serviceAccountJson: string) { + return createCredentialRecord( + { + workspaceId, + type: 'service_account', + userId, + providerId: DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, + displayName, + serviceAccountJson, + }, + { authorizeWorkspace: true } + ) +} + +async function reconnect(credentialId: string, serviceAccountJson: string) { + const [row] = await db.select().from(credential).where(eq(credential.id, credentialId)) + return updateCredentialRecord({ + credentialId, + credential: row, + actorUserId: userId, + serviceAccountJson, + }) +} + +describe('reusing a keyless trust setup', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Subject reuse fixture', + email: `${userId}@subject-reuse.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Subject reuse fixture', + ownerId: userId, + billedAccountUserId: userId, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const target = typeof input === 'string' || input instanceof URL ? String(input) : input.url + if (target === `https://${HOST}/oidc/v1/token`) { + const subjectToken = new URLSearchParams(String(init?.body)).get('subject_token') ?? '' + const subject = subjectToken.replace(/^token-for:/, '') + return pinnedSubjects.has(subject) + ? jsonResponse({ access_token: `access-for:${subject}` }) + : jsonResponse({ error: 'invalid_client' }, 401) + } + if (target === `https://${HOST}/api/2.0/preview/scim/v2/Me`) { + return jsonResponse({ userName: 'service-principal@example.com' }) + } + return originalFetch(input, init) + }) + }, 60_000) + + afterAll(async () => { + vi.unstubAllGlobals() + await db.delete(credential).where(eq(credential.workspaceId, workspaceId)) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, userId)) + }) + + it('refuses a second credential with a subject another credential holds', async () => { + const setup = grant() + const first = await create('Lakehouse', keyless(setup)) + expect(first.success).toBe(true) + + const second = await create('Lakehouse again', keyless(setup)) + + expect(second).toMatchObject({ success: false, errorCode: 'conflict' }) + const rows = await db + .select({ id: credential.id }) + .from(credential) + .where(eq(credential.workspaceId, workspaceId)) + expect(rows.map((row) => row.id)).toEqual([first.credential?.id]) + }) + + it('refuses reconnecting another credential onto a subject that is held', async () => { + const held = grant() + expect((await create('Held', keyless(held))).success).toBe(true) + const other = await create('Other', keyless(grant())) + const otherId = other.credential?.id ?? '' + + const result = await reconnect(otherId, keyless(held)) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + + it('lets the holding credential reconnect with the subject it already has', async () => { + const setup = grant() + const created = await create('Reconnects', keyless(setup)) + const credentialId = created.credential?.id ?? '' + + expect((await reconnect(credentialId, keyless())).success).toBe(true) + expect((await reconnect(credentialId, keyless(setup))).success).toBe(true) + }) + + it('lets only one of two concurrent creates of one subject through', async () => { + const setup = grant() + + const results = await Promise.all([ + create('Racer one', keyless(setup)), + create('Racer two', keyless(setup)), + ]) + + expect(results.filter((result) => result.success)).toHaveLength(1) + expect(results.filter((result) => result.errorCode === 'conflict')).toHaveLength(1) + }) + + it('lets only one of two concurrent reconnects onto one subject through', async () => { + const first = await create('Reconnect racer one', keyless(grant())) + const second = await create('Reconnect racer two', keyless(grant())) + const setup = grant() + + const results = await Promise.all([ + reconnect(first.credential?.id ?? '', keyless(setup)), + reconnect(second.credential?.id ?? '', keyless(setup)), + ]) + + expect(results.filter((result) => result.success)).toHaveLength(1) + expect(results.filter((result) => result.errorCode === 'conflict')).toHaveLength(1) + }) + + it('frees the subject once the credential holding it is deleted', async () => { + const setup = grant() + const created = await create('Deleted holder', keyless(setup)) + const credentialId = created.credential?.id ?? '' + await db.delete(credentialMember).where(eq(credentialMember.credentialId, credentialId)) + await db.delete(credential).where(eq(credential.id, credentialId)) + + expect((await create('Successor', keyless(setup))).success).toBe(true) + }) +}) diff --git a/apps/sim/lib/credentials/aws-service-account.server.ts b/apps/sim/lib/credentials/aws-service-account.server.ts index c0adb8e9546..6468e2a3d9f 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.ts @@ -258,13 +258,14 @@ function verificationFailure(config: AwsCredentialConfig, error: unknown): AwsCr /** * Validates the submitted configuration, proves it works by resolving it and - * calling `sts:GetCallerIdentity`, and returns the encrypted blob plus the - * caller ARN AWS reports. + * calling `sts:GetCallerIdentity`, and returns the encrypted blob, the caller + * ARN AWS reports, and the keyless subject it newly binds, if any (a + * reconnect keeping its stored subject binds none). */ export async function verifyAndEncryptAwsCredential( serialized: string, owner: WorkloadIdentityCredentialOwner -): Promise<{ encryptedServiceAccountKey: string; callerArn: string }> { +): Promise<{ encryptedServiceAccountKey: string; callerArn: string; newSubject?: string }> { const parsed = parseServiceAccountConfig(serialized, submittedConfigSchema, 'AWS credential') if (!parsed.success) throw new AwsCredentialError(parsed.message) const submitted = parsed.data @@ -292,7 +293,13 @@ export async function verifyAndEncryptAwsCredential( const { encrypted } = await encryptSecret( JSON.stringify({ type: AWS_CREDENTIAL_SECRET_TYPE, config }) ) - return { encryptedServiceAccountKey: encrypted, callerArn } + return { + encryptedServiceAccountKey: encrypted, + callerArn, + ...(config.authType === 'oidc_role' && config.subject !== readStoredSubject(owner.storedSecret) + ? { newSubject: config.subject } + : {}), + } } /** Resolves the configuration and returns the caller ARN AWS reports for it. */ diff --git a/apps/sim/lib/credentials/databricks-service-account.server.ts b/apps/sim/lib/credentials/databricks-service-account.server.ts index 8b3d46aba38..faee195884e 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.ts @@ -290,13 +290,14 @@ async function assertPolicyPinsSubject( /** * Validates the submitted configuration, proves it by calling the workspace's - * SCIM `Me` endpoint with the resolved token, and returns the encrypted blob - * plus the identity Databricks reports. + * SCIM `Me` endpoint with the resolved token, and returns the encrypted blob, + * the identity Databricks reports, and the keyless subject it newly binds, if + * any (a reconnect keeping its stored subject binds none). */ export async function verifyAndEncryptDatabricksCredential( serialized: string, owner: WorkloadIdentityCredentialOwner -): Promise<{ encryptedServiceAccountKey: string; identity: string }> { +): Promise<{ encryptedServiceAccountKey: string; identity: string; newSubject?: string }> { const parsed = parseServiceAccountConfig( serialized, submittedConfigSchema, @@ -325,7 +326,14 @@ export async function verifyAndEncryptDatabricksCredential( const { encrypted } = await encryptSecret( JSON.stringify({ type: DATABRICKS_CREDENTIAL_SECRET_TYPE, config }) ) - return { encryptedServiceAccountKey: encrypted, identity } + return { + encryptedServiceAccountKey: encrypted, + identity, + ...(config.authType === 'oidc_federation' && + config.subject !== readStoredSubject(owner.storedSecret) + ? { newSubject: config.subject } + : {}), + } } /** diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 01d864a489b..2c42414f9fd 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -29,6 +29,10 @@ import { } from '@/lib/credentials/service-account-secret' import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { + assertWorkloadIdentitySubjectUnclaimed, + WorkloadIdentitySubjectError, +} from '@/lib/credentials/workload-identity/subject' import { getServiceConfigByProviderId } from '@/lib/oauth' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { captureServerEvent } from '@/lib/posthog/server' @@ -269,6 +273,7 @@ export async function createCredentialRecord( : null let resolvedEnvOwnerUserId: string | null = null let resolvedEncryptedServiceAccountKey: string | null = null + let resolvedWorkloadIdentitySubject: string | undefined const extraAuditMetadata: Record = {} if (type === 'oauth') { @@ -335,6 +340,7 @@ export async function createCredentialRecord( resolvedEnvOwnerUserId = null if (!resolvedDisplayName) resolvedDisplayName = secret.displayName resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey + resolvedWorkloadIdentitySubject = secret.newWorkloadIdentitySubject Object.assign(extraAuditMetadata, secret.auditMetadata) } catch (error) { if (error instanceof ServiceAccountSecretError) { @@ -517,6 +523,19 @@ export async function createCredentialRecord( if (innerExisting) throw new DuplicateCredentialError() } + if (resolvedWorkloadIdentitySubject) { + try { + await assertWorkloadIdentitySubjectUnclaimed(tx, { + subject: resolvedWorkloadIdentitySubject, + scope, + }) + } catch (error) { + if (error instanceof WorkloadIdentitySubjectError) + return failure(error.message, 'conflict') + throw error + } + } + await tx.insert(credential).values({ id: credentialId, ...resourceScopeColumns(scope), diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index d514e42aaa5..24abca6fa83 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -47,6 +47,10 @@ import { verifyAndBuildServiceAccountSecret, } from '@/lib/credentials/service-account-secret' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { + assertWorkloadIdentitySubjectUnclaimed, + WorkloadIdentitySubjectError, +} from '@/lib/credentials/workload-identity/subject' import { invalidateEffectiveDecryptedEnvCache } from '@/lib/environment/utils' import { findSlackSearchInstallation } from '@/lib/knowledge/application/slack-search/repository' import { @@ -312,6 +316,7 @@ export async function updateCredentialRecord( let rotatedSlackBotUserId: string | undefined let rotatedAuditMetadata: Record | undefined + let rotatedWorkloadIdentitySubject: string | undefined if (hasRotationSecret) { const providerId = params.credential.providerId ?? '' @@ -453,6 +458,7 @@ export async function updateCredentialRecord( updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey rotatedSlackBotUserId = secret.botUserId rotatedAuditMetadata = secret.auditMetadata + rotatedWorkloadIdentitySubject = secret.newWorkloadIdentitySubject if (needsStoredIdentity) { const previousIdentity = deriveStoredDisplayName(storedBlob) @@ -515,7 +521,26 @@ export async function updateCredentialRecord( } updates.updatedAt = new Date() - await db.update(credential).set(updates).where(eq(credential.id, params.credentialId)) + if (rotatedWorkloadIdentitySubject) { + const subject = rotatedWorkloadIdentitySubject + try { + await db.transaction(async (tx) => { + await assertWorkloadIdentitySubjectUnclaimed(tx, { + subject, + scope: resourceScopeFromOwner(params.credential), + credentialId: params.credentialId, + }) + await tx.update(credential).set(updates).where(eq(credential.id, params.credentialId)) + }) + } catch (error) { + if (error instanceof WorkloadIdentitySubjectError) { + return { success: false, error: error.message, errorCode: 'conflict' } + } + throw error + } + } else { + await db.update(credential).set(updates).where(eq(credential.id, params.credentialId)) + } // The flag rides the environment snapshot into every run's redaction catalog, so a flip // must not serve stale from the same-process snapshot cache. Cross-process readers are diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 4b5509ea62b..1535ea1eb14 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -104,6 +104,11 @@ export interface ServiceAccountSecretResult { principal: ServiceAccountPrincipal | null /** Slack custom bot: the derived bot user id (for reaction self-drop). */ botUserId?: string + /** + * Keyless AWS or Databricks: a subject this save binds that the credential did + * not already hold, which must not belong to another credential of the owner. + */ + newWorkloadIdentitySubject?: string } /** Thrown when a service-account secret is missing or fails provider verification. */ @@ -272,6 +277,7 @@ async function buildAwsServiceAccountSecret( displayName: result.callerArn, auditMetadata: serviceAccountPrincipalMetadata(principal), principal, + newWorkloadIdentitySubject: result.newSubject, } } @@ -299,6 +305,7 @@ async function buildDatabricksServiceAccountSecret( displayName: result.identity, auditMetadata: serviceAccountPrincipalMetadata(principal), principal, + newWorkloadIdentitySubject: result.newSubject, } } diff --git a/apps/sim/lib/credentials/workload-identity/subject.ts b/apps/sim/lib/credentials/workload-identity/subject.ts index 6a0fed7df19..648599ceee4 100644 --- a/apps/sim/lib/credentials/workload-identity/subject.ts +++ b/apps/sim/lib/credentials/workload-identity/subject.ts @@ -1,9 +1,20 @@ import { createHmac } from 'node:crypto' +import { credential } from '@sim/db/schema' import { safeCompare } from '@sim/security/compare' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { and, inArray, isNull, ne, sql } from 'drizzle-orm' import { env } from '@/lib/core/config/env' +import type { ResourceScope } from '@/lib/core/resource-scope' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { decryptSecret } from '@/lib/core/security/encryption' import type { WorkloadIdentityProvider } from '@/lib/credentials/workload-identity/audiences' import type { WorkloadIdentityOwner } from '@/lib/credentials/workload-identity/issuer' +import type { DbOrTx } from '@/lib/db/types' +import { + AWS_SERVICE_ACCOUNT_PROVIDER_ID, + DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, +} from '@/lib/oauth/types' /** * Credential-scoped workload-identity subjects. @@ -145,3 +156,54 @@ export function bindWorkloadIdentitySubject(input: { verifyWorkloadIdentitySubject(provider, owner, owner.actorUserId, subject, subjectProof) return subject } + +/** Providers whose stored secret can hold a bound subject at `config.subject`. */ +const KEYLESS_PROVIDER_IDS = [ + AWS_SERVICE_ACCOUNT_PROVIDER_ID, + DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, +] + +/** + * Refuses a subject that another live credential of the same owner already + * holds. A proof stays valid for a day and is not tied to one credential, so + * without this a member removed from a keyless credential could save a second + * credential with the same subject and keep assuming the customer's role. + * + * The subject lives only inside each credential's encrypted secret, so this + * decrypts the owner's keyless credentials (one owner holds few) rather than + * relying on a unique index. Call it inside the transaction that writes the + * credential: the advisory lock holds concurrent saves of one subject until + * commit, so the later save sees the earlier row. + * + * @param credentialId The credential being reconnected, which may keep its own subject. + */ +export async function assertWorkloadIdentitySubjectUnclaimed( + tx: DbOrTx, + input: { subject: string; scope: ResourceScope; credentialId?: string } +): Promise { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`workload-identity-subject:${input.subject}`}, 0))` + ) + const rows = await tx + .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) + .from(credential) + .where( + and( + resourceScopeCondition(credential, input.scope), + inArray(credential.providerId, KEYLESS_PROVIDER_IDS), + isNull(credential.revokedAt), + input.credentialId ? ne(credential.id, input.credentialId) : undefined + ) + ) + for (const row of rows) { + if (!row.encryptedServiceAccountKey) continue + const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) + const stored: unknown = JSON.parse(decrypted) + const config = isRecordLike(stored) ? stored.config : undefined + if (isRecordLike(config) && config.subject === input.subject) { + throw new WorkloadIdentitySubjectError( + 'Another credential already uses this trust setup. Start the keyless connection again.' + ) + } + } +} From 88a8a851af07678fe6f68c12c9a137c9302f8c8e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:36:10 -0800 Subject: [PATCH 07/15] refactor(tools): detect the saved-credential picker with isSavedCredentialPicker credentialSuppliedParamsFor matched any oauth-input feeding oauthCredential, without the credentialKind === 'service-account' check isSavedCredentialPicker applies. It now uses that predicate. No registered block changes: every block whose loose-match picker lacks the service-account kind has no tool that declares credentialSuppliedParams. --- apps/sim/tools/shared/saved-credentials.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/sim/tools/shared/saved-credentials.ts b/apps/sim/tools/shared/saved-credentials.ts index c0cebd3a3ac..9d164c96b1c 100644 --- a/apps/sim/tools/shared/saved-credentials.ts +++ b/apps/sim/tools/shared/saved-credentials.ts @@ -78,12 +78,9 @@ export function isSavedCredentialPicker( */ export function credentialSuppliedParamsFor( oauth: Pick | undefined, - subBlocks: readonly Pick[] + subBlocks: readonly Pick[] ): ReadonlySet { - const hasPicker = subBlocks.some( - (subBlock) => subBlock.type === 'oauth-input' && subBlock.canonicalParamId === 'oauthCredential' - ) - return new Set(hasPicker ? oauth?.credentialSuppliedParams : []) + return new Set(subBlocks.some(isSavedCredentialPicker) ? oauth?.credentialSuppliedParams : []) } /** From d5d8547f7dba8531bf80e2c901080e59e57e413a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:26:12 -0800 Subject: [PATCH 08/15] fix(runtime-secrets): fetch the boot secret through the configured proxy The Secrets Manager client used the SDK's Node handler, which ignores HTTPS_PROXY, so a container whose only egress is a proxy could not load its env and never booted. Under Bun with a proxy set, send through Bun's fetch, which honors HTTPS_PROXY/HTTP_PROXY/NO_PROXY. apps/sim's awsRequestHandler is out of reach for packages and the realtime bootstrap needs the same path. --- bun.lock | 1 + packages/runtime-secrets/package.json | 3 +- packages/runtime-secrets/src/index.ts | 25 +++++++- .../runtime-secrets/src/proxy-egress.test.ts | 58 +++++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 packages/runtime-secrets/src/proxy-egress.test.ts diff --git a/bun.lock b/bun.lock index fc707d68286..25b6c332b17 100644 --- a/bun.lock +++ b/bun.lock @@ -724,6 +724,7 @@ "@aws-sdk/client-secrets-manager": "3.1150.0", "@sim/logger": "workspace:*", "@sim/utils": "workspace:*", + "@smithy/fetch-http-handler": "5.8.0", }, "devDependencies": { "@sim/tsconfig": "workspace:*", diff --git a/packages/runtime-secrets/package.json b/packages/runtime-secrets/package.json index eceaa6335e8..5f61de7e43e 100644 --- a/packages/runtime-secrets/package.json +++ b/packages/runtime-secrets/package.json @@ -31,7 +31,8 @@ "dependencies": { "@aws-sdk/client-secrets-manager": "3.1150.0", "@sim/logger": "workspace:*", - "@sim/utils": "workspace:*" + "@sim/utils": "workspace:*", + "@smithy/fetch-http-handler": "5.8.0" }, "devDependencies": { "@sim/tsconfig": "workspace:*", diff --git a/packages/runtime-secrets/src/index.ts b/packages/runtime-secrets/src/index.ts index c1119e69fe0..e4c2028346a 100644 --- a/packages/runtime-secrets/src/index.ts +++ b/packages/runtime-secrets/src/index.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' +import { FetchHttpHandler } from '@smithy/fetch-http-handler' const logger = createLogger('RuntimeSecrets') @@ -56,9 +57,11 @@ export async function loadRuntimeSecrets(): Promise { * times, then throws, so a caller never proceeds on a partial or missing secret. */ export async function fetchRuntimeSecrets(secretId: string): Promise> { - const client = new SecretsManagerClient( - process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {} - ) + const requestHandler = proxyAwareRequestHandler() + const client = new SecretsManagerClient({ + ...(process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {}), + ...(requestHandler ? { requestHandler } : {}), + }) const entries = parseSecretJson(await fetchSecretString(client, secretId)) return Object.fromEntries( Object.entries(entries).map(([key, value]) => [ @@ -68,6 +71,22 @@ export async function fetchRuntimeSecrets(secretId: string): Promise process.env[name]?.trim())) return undefined + return new FetchHttpHandler() +} + async function fetchSecretString(client: SecretsManagerClient, secretId: string): Promise { const response = await sendWithRetry(client, secretId) if (!response.SecretString) { diff --git a/packages/runtime-secrets/src/proxy-egress.test.ts b/packages/runtime-secrets/src/proxy-egress.test.ts new file mode 100644 index 00000000000..cde9087ab13 --- /dev/null +++ b/packages/runtime-secrets/src/proxy-egress.test.ts @@ -0,0 +1,58 @@ +import { execFile } from 'node:child_process' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const tunnels: string[] = [] +let proxy: Server +let proxyUrl = '' + +beforeAll(async () => { + proxy = createServer((_req, res) => res.end()) + proxy.on('connect', (req, socket) => { + socket.on('error', () => {}) + tunnels.push(String(req.url)) + socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n') + }) + await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve)) + proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}` +}) + +afterAll(async () => { + proxy.closeAllConnections() + await new Promise((resolve) => proxy.close(() => resolve())) +}) + +describe('loadRuntimeSecrets under a proxy-only egress', () => { + it('reaches Secrets Manager through HTTPS_PROXY under the Bun runtime', async () => { + const entry = fileURLToPath(new URL('./index.ts', import.meta.url)) + const run = promisify(execFile)( + 'bun', + [ + '--no-env-file', + '-e', + `await (await import(${JSON.stringify(entry)})).loadRuntimeSecrets()`, + ], + { + timeout: 20_000, + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + SIM_ENV_SECRET_ID: '/test/sim/env-vars', + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY_ID: 'AKIDEXAMPLE', + AWS_SECRET_ACCESS_KEY: 'secret', + AWS_CONFIG_FILE: '/dev/null', + AWS_SHARED_CREDENTIALS_FILE: '/dev/null', + AWS_ENDPOINT_URL_SECRETS_MANAGER: 'https://secrets.invalid', + HTTPS_PROXY: proxyUrl, + NO_PROXY: '', + }, + } + ) + await expect(run).rejects.toThrow(/Failed to fetch runtime secrets/) + expect(tunnels).toContain('secrets.invalid:443') + }, 30_000) +}) From 638b45783d36654e3870ff55628cfb642867f909 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:24:58 -0800 Subject: [PATCH 09/15] fix(models): mark NVIDIA Nemotron 3 Super retired and move the NVIDIA default NVIDIA's model page states the nvidia/nemotron-3-super-120b-a12b API is no longer supported after 2026-10-19, and Pi 1.0.1 dropped it as its NVIDIA default for the same reason. Mark it deprecated (hidden from pickers, still a known id for pinned workflows) and default the NVIDIA provider to Nemotron 3 Ultra, which the Pi catalog also serves. The Mistral IDs Pi 1.5 dropped (mistral-large-2411, devstral-small-2507, devstral-medium-2507) were retired by Mistral on 2026-05-31 and are already deprecated here, so saved Pi blocks fail before credentials with the choose-a-model error instead of being mapped onto a different model. --- apps/sim/providers/models.ts | 4 ++-- apps/sim/providers/pi-providers.test.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 92494ad0d48..0b62ae529b1 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -3971,7 +3971,7 @@ export const PROVIDER_DEFINITIONS: Record = { id: 'nvidia', name: 'NVIDIA NIM', description: "NVIDIA's Nemotron models via NIM's OpenAI-compatible API", - defaultModel: 'nvidia/nemotron-3-super-120b-a12b', + defaultModel: 'nvidia/nemotron-3-ultra-550b-a55b', modelPatterns: [/^nvidia\//], icon: NvidiaIcon, color: '#77B900', @@ -4069,7 +4069,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1048576, releaseDate: '2026-03-11', - recommended: true, + sunset: { status: 'deprecated' }, }, { id: 'nvidia/nemotron-3-ultra-550b-a55b', diff --git a/apps/sim/providers/pi-providers.test.ts b/apps/sim/providers/pi-providers.test.ts index 0d54440f763..70dbeebcb1c 100644 --- a/apps/sim/providers/pi-providers.test.ts +++ b/apps/sim/providers/pi-providers.test.ts @@ -32,11 +32,15 @@ describe('Pi provider catalog', () => { ) }) - it('rejects saved model IDs removed by Pi 1.0 without substituting another model', () => { + it('rejects saved model IDs removed by Pi without substituting another model', () => { expect(resolvePiModelId('anthropic', 'claude-opus-4-1')).toBeUndefined() expect(resolvePiModelId('cerebras', 'cerebras/zai-glm-4.7')).toBeUndefined() expect(resolvePiModelId('zai', 'glm-5.1')).toBeUndefined() expect(resolvePiModelId('zai', 'glm-4.5-air')).toBeUndefined() + expect(resolvePiModelId('mistral', 'mistral-large-2411')).toBeUndefined() + expect(resolvePiModelId('mistral', 'devstral-small-2507')).toBeUndefined() + expect(resolvePiModelId('mistral', 'devstral-medium-2507')).toBeUndefined() + expect(resolvePiModelId('nvidia', 'nvidia/nemotron-3-super-120b-a12b')).toBeUndefined() }) it('rejects provider/model pairs absent from the installed Pi catalog', () => { From b96b95232db5be93d61a4548a5c23470fdf33370 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:49:46 -0800 Subject: [PATCH 10/15] fix(credentials): lock keyless subject checks through the advisory-lock helper and refuse on unreadable siblings assertWorkloadIdentitySubjectUnclaimed now takes a DbTransaction and locks through acquireAdvisoryXactLock, so the lock carries its SQLCommenter tag and cannot be taken on the pool by mistake. A sibling keyless credential whose secret does not decrypt or parse used to throw a generic error, failing every keyless create or rotation for the owner with a 500. Since it might hold the subject, the save is now refused as a conflict naming that credential, with a warning logged, and stored subjects are read with a schema safeParse. --- ...load-identity-subject-reuse.integration.ts | 28 +++++++++ .../credentials/workload-identity/subject.ts | 59 +++++++++++++++---- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts b/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts index 3a9c9462720..355f39a9788 100644 --- a/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts +++ b/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts @@ -19,6 +19,7 @@ vi.mock('@/lib/credentials/workload-identity/issuer', async (importOriginal) => mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `token-for:${subject}`, })) +import { encryptSecret } from '@/lib/core/security/encryption' import { updateCredentialRecord } from '@/lib/credentials/orchestration' import { createCredentialRecord } from '@/lib/credentials/orchestration/credential-create' import { issueWorkloadIdentitySubject } from '@/lib/credentials/workload-identity/subject' @@ -191,4 +192,31 @@ describe('reusing a keyless trust setup', () => { expect((await create('Successor', keyless(setup))).success).toBe(true) }) + + it.each([ + ['ciphertext that does not decrypt', async () => 'not-a-ciphertext'], + ['a secret that is not JSON', async () => (await encryptSecret('not json')).encrypted], + ])( + 'refuses a new subject while a sibling holds %s, instead of failing the save', + async (_case, sealed) => { + const unreadableId = generateId() + await db.insert(credential).values({ + id: unreadableId, + workspaceId, + type: 'service_account', + providerId: DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, + displayName: 'Unreadable sibling', + encryptedServiceAccountKey: await sealed(), + createdBy: userId, + }) + try { + const result = await create(`Blocked by ${unreadableId}`, keyless(grant())) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(result.error).toContain('Unreadable sibling') + } finally { + await db.delete(credential).where(eq(credential.id, unreadableId)) + } + } + ) }) diff --git a/apps/sim/lib/credentials/workload-identity/subject.ts b/apps/sim/lib/credentials/workload-identity/subject.ts index 648599ceee4..889991d1cb8 100644 --- a/apps/sim/lib/credentials/workload-identity/subject.ts +++ b/apps/sim/lib/credentials/workload-identity/subject.ts @@ -1,21 +1,25 @@ import { createHmac } from 'node:crypto' import { credential } from '@sim/db/schema' +import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { generateId } from '@sim/utils/id' -import { isRecordLike } from '@sim/utils/object' -import { and, inArray, isNull, ne, sql } from 'drizzle-orm' +import { and, inArray, isNull, ne } from 'drizzle-orm' +import { z } from 'zod' import { env } from '@/lib/core/config/env' import type { ResourceScope } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { decryptSecret } from '@/lib/core/security/encryption' import type { WorkloadIdentityProvider } from '@/lib/credentials/workload-identity/audiences' import type { WorkloadIdentityOwner } from '@/lib/credentials/workload-identity/issuer' -import type { DbOrTx } from '@/lib/db/types' +import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' +import type { DbTransaction } from '@/lib/db/types' import { AWS_SERVICE_ACCOUNT_PROVIDER_ID, DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, } from '@/lib/oauth/types' +const logger = createLogger('WorkloadIdentitySubject') + /** * Credential-scoped workload-identity subjects. * @@ -163,6 +167,22 @@ const KEYLESS_PROVIDER_IDS = [ DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, ] +/** The part of an AWS or Databricks stored secret that can hold a bound subject. */ +const storedSubjectSchema = z.object({ + config: z.object({ subject: z.string().optional() }), +}) + +/** The subject a stored secret holds, `undefined` for a keyed mode, or `null` when unreadable. */ +async function readStoredSubject(encrypted: string): Promise { + try { + const { decrypted } = await decryptSecret(encrypted) + const parsed = storedSubjectSchema.safeParse(JSON.parse(decrypted)) + return parsed.success ? parsed.data.config.subject : null + } catch { + return null + } +} + /** * Refuses a subject that another live credential of the same owner already * holds. A proof stays valid for a day and is not tied to one credential, so @@ -171,21 +191,28 @@ const KEYLESS_PROVIDER_IDS = [ * * The subject lives only inside each credential's encrypted secret, so this * decrypts the owner's keyless credentials (one owner holds few) rather than - * relying on a unique index. Call it inside the transaction that writes the + * relying on a unique index. It runs inside the transaction that writes the * credential: the advisory lock holds concurrent saves of one subject until - * commit, so the later save sees the earlier row. + * commit, so the later save sees the earlier row. A sibling whose secret cannot + * be read might hold the subject, so it refuses the save rather than skip it. * * @param credentialId The credential being reconnected, which may keep its own subject. */ export async function assertWorkloadIdentitySubjectUnclaimed( - tx: DbOrTx, + tx: DbTransaction, input: { subject: string; scope: ResourceScope; credentialId?: string } ): Promise { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`workload-identity-subject:${input.subject}`}, 0))` + await acquireAdvisoryXactLock( + tx, + 'workload_identity_subject', + `workload-identity-subject:${input.subject}` ) const rows = await tx - .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) + .select({ + id: credential.id, + displayName: credential.displayName, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) .from(credential) .where( and( @@ -197,10 +224,16 @@ export async function assertWorkloadIdentitySubjectUnclaimed( ) for (const row of rows) { if (!row.encryptedServiceAccountKey) continue - const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) - const stored: unknown = JSON.parse(decrypted) - const config = isRecordLike(stored) ? stored.config : undefined - if (isRecordLike(config) && config.subject === input.subject) { + const storedSubject = await readStoredSubject(row.encryptedServiceAccountKey) + if (storedSubject === null) { + logger.warn('Unreadable keyless credential blocks a subject uniqueness check', { + credentialId: row.id, + }) + throw new WorkloadIdentitySubjectError( + `Sim could not read the saved credential "${row.displayName}", so it cannot confirm this trust setup is unused. Reconnect or delete that credential, then try again.` + ) + } + if (storedSubject === input.subject) { throw new WorkloadIdentitySubjectError( 'Another credential already uses this trust setup. Start the keyless connection again.' ) From 76e86182f8b921aaea74f770de520376e925cb49 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:51:17 -0800 Subject: [PATCH 11/15] fix(network): match NO_PROXY entries like curl, including bare IPv6 and CIDR A bare IPv6 entry such as ::1 was read as host ':' port 1 and never matched, IP entries only matched their exact spelling, and CIDR ranges were ignored. Entries now follow curl: domains cover their subdomains, bracketed IPv6 may carry a port, IP entries match any spelling, and CIDR ranges match IP-literal destinations. NO_PROXY is parsed once per distinct value. The comments and the self-hosting networking page describe these rules, and note that Bun's default fetch applies its own. --- .../docs/platform/self-hosting/networking.mdx | 2 + .../network/environment-proxy.server.test.ts | 60 ++++++++++++ .../core/network/environment-proxy.server.ts | 92 ++++++++++++++++--- 3 files changed, 141 insertions(+), 13 deletions(-) diff --git a/apps/docs/content/docs/platform/self-hosting/networking.mdx b/apps/docs/content/docs/platform/self-hosting/networking.mdx index 1a995ac67c5..16e76d27d2c 100644 --- a/apps/docs/content/docs/platform/self-hosting/networking.mdx +++ b/apps/docs/content/docs/platform/self-hosting/networking.mdx @@ -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, 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 diff --git a/apps/sim/lib/core/network/environment-proxy.server.test.ts b/apps/sim/lib/core/network/environment-proxy.server.test.ts index f63b1ce0b8d..ca4e1d7e45e 100644 --- a/apps/sim/lib/core/network/environment-proxy.server.test.ts +++ b/apps/sim/lib/core/network/environment-proxy.server.test.ts @@ -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: '*' }, diff --git a/apps/sim/lib/core/network/environment-proxy.server.ts b/apps/sim/lib/core/network/environment-proxy.server.ts index c43f6f7bfa1..f7649425f6b 100644 --- a/apps/sim/lib/core/network/environment-proxy.server.ts +++ b/apps/sim/lib/core/network/environment-proxy.server.ts @@ -1,6 +1,12 @@ +import { BlockList, isIP } from 'node:net' +import { unwrapIpv6Brackets } from '@sim/security/hostnames' + const DEFAULT_PORTS: Record = { 'http:': 80, 'https:': 443 } -/** An empty lowercase variable counts as unset, so it cannot hide the uppercase one. */ +/** + * Lowercase wins when set, but an empty lowercase variable counts as unset, so it cannot hide + * the uppercase one (undici's `EnvHttpProxyAgent` lets an empty `no_proxy` hide `NO_PROXY`). + */ function readEnv(lower: string, upper: string): string | undefined { return process.env[lower]?.trim() || process.env[upper]?.trim() || undefined } @@ -10,27 +16,87 @@ export function isEnvironmentProxyConfigured(): boolean { return Boolean(readEnv('https_proxy', 'HTTPS_PROXY') ?? readEnv('http_proxy', 'HTTP_PROXY')) } -/** Matches `NO_PROXY` the way undici's `EnvHttpProxyAgent` does, so both stacks agree. */ +type NoProxyRule = + | { kind: 'domain'; domain: string; port: number } + | { kind: 'address'; family: 'ipv4' | 'ipv6'; addresses: BlockList; port: number } + +function ipFamily(address: string): 'ipv4' | 'ipv6' | null { + const version = isIP(address) + return version === 4 ? 'ipv4' : version === 6 ? 'ipv6' : null +} + +/** + * Parses one `NO_PROXY` entry, or null when it can never match. A bracketed IPv6 address may + * carry a port (`[::1]:8080`); a bare one (`::1`) never does, since its last group reads as one. + */ +function parseNoProxyEntry(entry: string): NoProxyRule | null { + let host = entry + let port = 0 + const bracketed = entry.match(/^\[([^\]]+)\](?::(\d+))?$/) + const withPort = entry.match(/^(.+):(\d+)$/) + if (bracketed) { + host = bracketed[1] + port = bracketed[2] ? Number(bracketed[2]) : 0 + } else if (withPort && isIP(entry.split('/')[0]) !== 6) { + host = withPort[1] + port = Number(withPort[2]) + } + const [address, prefix] = host.split('/') + const family = ipFamily(address) + if (!family) return { kind: 'domain', domain: host.replace(/^\*?\./, '').toLowerCase(), port } + const addresses = new BlockList() + try { + if (prefix === undefined) addresses.addAddress(address, family) + else if (/^\d+$/.test(prefix)) addresses.addSubnet(address, Number(prefix), family) + else return null + } catch { + return null + } + return { kind: 'address', family, addresses, port } +} + +/** The last parsed `NO_PROXY`, so each request reuses its rules until the value changes. */ +let parsedNoProxy: { raw: string; rules: NoProxyRule[] } | null = null + +function noProxyRules(raw: string): NoProxyRule[] { + if (parsedNoProxy?.raw !== raw) { + const rules = raw + .split(/[,\s]/) + .filter(Boolean) + .map(parseNoProxyEntry) + .filter((rule): rule is NoProxyRule => rule !== null) + parsedNoProxy = { raw, rules } + } + return parsedNoProxy.rules +} + +/** + * Whether `NO_PROXY` sends this destination direct, with curl's matching: `*` matches + * everything; `example.com`, `.example.com` and `*.example.com` all match the domain and + * every subdomain; `host:port` and `[v6]:port` match only that port; IP entries match any + * spelling of the address; and `10.0.0.0/8` or `fd00::/8` match IP-literal destinations in + * the range (never a hostname, which is not resolved here). + */ function isExcludedByNoProxy(hostname: string, port: number): boolean { const noProxy = readEnv('no_proxy', 'NO_PROXY') if (!noProxy) return false if (noProxy === '*') return true - for (const entry of noProxy.split(/[,\s]/)) { - if (!entry) continue - const withPort = entry.match(/^(.+):(\d+)$/) - const entryHost = (withPort ? withPort[1] : entry).replace(/^\*?\./, '').toLowerCase() - const entryPort = withPort ? Number.parseInt(withPort[2], 10) : 0 - if (entryPort && entryPort !== port) continue - if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true - } - return false + const host = unwrapIpv6Brackets(hostname.toLowerCase()) + const hostFamily = ipFamily(host) + return noProxyRules(noProxy).some((rule) => { + if (rule.port && rule.port !== port) return false + if (rule.kind === 'address') { + return hostFamily === rule.family && rule.addresses.check(host, hostFamily) + } + return host === rule.domain || host.endsWith(`.${rule.domain}`) + }) } /** * The operator's `HTTPS_PROXY` / `HTTP_PROXY` for a destination, or null when none applies. * Lowercase names win, an https destination falls back to `HTTP_PROXY`, and `NO_PROXY` - * excludes hosts with undici's `EnvHttpProxyAgent` matching, so guarded requests leave the - * way the default `fetch` does. Read per call so a value set after startup is honored. + * excludes hosts as {@link isExcludedByNoProxy} describes. Read per call so a value set + * after startup is honored. */ export function resolveEnvironmentProxy(target: URL): string | null { const defaultPort = DEFAULT_PORTS[target.protocol] From aefa2e476dac091cdb3c25c02a1bf09b4692b5db Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:51:38 -0800 Subject: [PATCH 12/15] docs(network): note the boot secret fetch follows Bun's NO_PROXY rules --- apps/docs/content/docs/platform/self-hosting/networking.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/docs/platform/self-hosting/networking.mdx b/apps/docs/content/docs/platform/self-hosting/networking.mdx index 16e76d27d2c..5e1daea44c8 100644 --- a/apps/docs/content/docs/platform/self-hosting/networking.mdx +++ b/apps/docs/content/docs/platform/self-hosting/networking.mdx @@ -301,7 +301,7 @@ 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, 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. +For the SSRF guard, AWS clients, 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 and, behind a proxy, the boot-time Secrets Manager fetch, 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: From 297f796f6bc2edb06a7d87f20eb08b5a1aa630ab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 16:10:39 -0800 Subject: [PATCH 13/15] fix(credentials): decide subject reuse under the lock on every keyless reconnect A reconnect that kept its subject skipped the uniqueness check, but whether it kept the subject was decided from the secret read before provider verification. If another reconnect moved the credential to a new subject and a second credential claimed the old one meanwhile, the stale reconnect wrote the old subject back unchecked and two credentials held it. Every keyless reconnect now locks the subject it is writing, then the credential row (FOR UPDATE), re-reads the row's current subject, and runs the sibling check whenever the two differ, with the decision and the write in one transaction. --- ...load-identity-subject-reuse.integration.ts | 21 +++++ .../lib/credentials/orchestration/index.ts | 14 +-- .../credentials/workload-identity/subject.ts | 90 ++++++++++++++----- 3 files changed, 96 insertions(+), 29 deletions(-) diff --git a/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts b/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts index 355f39a9788..e06a15cc191 100644 --- a/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts +++ b/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts @@ -34,6 +34,8 @@ const originalFetch = globalThis.fetch /** Subjects the fixture service principal's federation policy pins. */ const pinnedSubjects = new Set() +/** Runs once inside the next token exchange, while a save is still verifying. */ +let duringVerification: (() => Promise) | undefined function grant() { const issued = issueWorkloadIdentitySubject('databricks', owner, userId) @@ -102,6 +104,9 @@ describe('reusing a keyless trust setup', () => { vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { const target = typeof input === 'string' || input instanceof URL ? String(input) : input.url if (target === `https://${HOST}/oidc/v1/token`) { + const interleaved = duringVerification + duringVerification = undefined + await interleaved?.() const subjectToken = new URLSearchParams(String(init?.body)).get('subject_token') ?? '' const subject = subjectToken.replace(/^token-for:/, '') return pinnedSubjects.has(subject) @@ -157,6 +162,22 @@ describe('reusing a keyless trust setup', () => { expect((await reconnect(credentialId, keyless(setup))).success).toBe(true) }) + it('re-checks a kept subject under the lock when the credential moved off it during verification', async () => { + const original = grant() + const mover = await create('Mover', keyless(original)) + const moverId = mover.credential?.id ?? '' + const moved = grant() + duringVerification = async () => { + expect((await reconnect(moverId, keyless(moved))).success).toBe(true) + expect((await create('Claimer', keyless(original))).success).toBe(true) + } + + const stale = await reconnect(moverId, keyless()) + + expect(duringVerification).toBeUndefined() + expect(stale).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + it('lets only one of two concurrent creates of one subject through', async () => { const setup = grant() diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 24abca6fa83..59c45b85677 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -48,7 +48,7 @@ import { } from '@/lib/credentials/service-account-secret' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { - assertWorkloadIdentitySubjectUnclaimed, + assertReconnectedSubjectUnclaimed, WorkloadIdentitySubjectError, } from '@/lib/credentials/workload-identity/subject' import { invalidateEffectiveDecryptedEnvCache } from '@/lib/environment/utils' @@ -316,7 +316,7 @@ export async function updateCredentialRecord( let rotatedSlackBotUserId: string | undefined let rotatedAuditMetadata: Record | undefined - let rotatedWorkloadIdentitySubject: string | undefined + let rotatedKeylessSecret: string | undefined if (hasRotationSecret) { const providerId = params.credential.providerId ?? '' @@ -458,7 +458,7 @@ export async function updateCredentialRecord( updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey rotatedSlackBotUserId = secret.botUserId rotatedAuditMetadata = secret.auditMetadata - rotatedWorkloadIdentitySubject = secret.newWorkloadIdentitySubject + if (needsStoredBoundSubject) rotatedKeylessSecret = secret.encryptedServiceAccountKey if (needsStoredIdentity) { const previousIdentity = deriveStoredDisplayName(storedBlob) @@ -521,12 +521,12 @@ export async function updateCredentialRecord( } updates.updatedAt = new Date() - if (rotatedWorkloadIdentitySubject) { - const subject = rotatedWorkloadIdentitySubject + if (rotatedKeylessSecret) { + const rotatedSecret = rotatedKeylessSecret try { await db.transaction(async (tx) => { - await assertWorkloadIdentitySubjectUnclaimed(tx, { - subject, + await assertReconnectedSubjectUnclaimed(tx, { + rotatedSecret, scope: resourceScopeFromOwner(params.credential), credentialId: params.credentialId, }) diff --git a/apps/sim/lib/credentials/workload-identity/subject.ts b/apps/sim/lib/credentials/workload-identity/subject.ts index 889991d1cb8..6b0276c252e 100644 --- a/apps/sim/lib/credentials/workload-identity/subject.ts +++ b/apps/sim/lib/credentials/workload-identity/subject.ts @@ -3,7 +3,7 @@ import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { generateId } from '@sim/utils/id' -import { and, inArray, isNull, ne } from 'drizzle-orm' +import { and, eq, inArray, isNull, ne } from 'drizzle-orm' import { z } from 'zod' import { env } from '@/lib/core/config/env' import type { ResourceScope } from '@/lib/core/resource-scope' @@ -183,30 +183,19 @@ async function readStoredSubject(encrypted: string): Promise { - await acquireAdvisoryXactLock( +function lockSubject(tx: DbTransaction, subject: string): Promise { + return acquireAdvisoryXactLock( tx, 'workload_identity_subject', - `workload-identity-subject:${input.subject}` + `workload-identity-subject:${subject}` ) +} + +/** Throws unless no live keyless credential of the owner other than `credentialId` holds `subject`. */ +async function assertNoOtherCredentialHolds( + tx: DbTransaction, + input: { subject: string; scope: ResourceScope; credentialId?: string } +): Promise { const rows = await tx .select({ id: credential.id, @@ -240,3 +229,60 @@ export async function assertWorkloadIdentitySubjectUnclaimed( } } } + +/** + * Refuses a subject that another live credential of the same owner already + * holds. A proof stays valid for a day and is not tied to one credential, so + * without this a member removed from a keyless credential could save a second + * credential with the same subject and keep assuming the customer's role. + * + * The subject lives only inside each credential's encrypted secret, so this + * decrypts the owner's keyless credentials (one owner holds few) rather than + * relying on a unique index. It runs inside the transaction that writes the + * credential: the advisory lock holds concurrent saves of one subject until + * commit, so the later save sees the earlier row. A sibling whose secret cannot + * be read might hold the subject, so it refuses the save rather than skip it. + */ +export async function assertWorkloadIdentitySubjectUnclaimed( + tx: DbTransaction, + input: { subject: string; scope: ResourceScope } +): Promise { + await lockSubject(tx, input.subject) + await assertNoOtherCredentialHolds(tx, input) +} + +/** + * The reconnect counterpart of {@link assertWorkloadIdentitySubjectUnclaimed}, + * run in the transaction that writes `rotatedSecret` onto `credentialId`. + * + * Whether the reconnect keeps the credential's subject is decided here, from + * the row as it stands under the lock, not from the secret read before + * provider verification: while that verification ran, another reconnect could + * have moved this credential to a different subject and a second credential + * could have claimed the old one, so writing it back unchecked would leave two + * credentials holding it. Locks the subject first, then the credential row; no + * path takes a subject lock while holding a credential row, so the order cannot + * deadlock. + */ +export async function assertReconnectedSubjectUnclaimed( + tx: DbTransaction, + input: { rotatedSecret: string; scope: ResourceScope; credentialId: string } +): Promise { + const subject = await readStoredSubject(input.rotatedSecret) + if (!subject) return + await lockSubject(tx, subject) + const [current] = await tx + .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) + .from(credential) + .where(eq(credential.id, input.credentialId)) + .for('update') + const currentSubject = current?.encryptedServiceAccountKey + ? await readStoredSubject(current.encryptedServiceAccountKey) + : undefined + if (currentSubject === subject) return + await assertNoOtherCredentialHolds(tx, { + subject, + scope: input.scope, + credentialId: input.credentialId, + }) +} From f788124a6fb3a9f60d720fee1caf671b080b691d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 16:11:31 -0800 Subject: [PATCH 14/15] fix(runtime-secrets): pick the boot fetch's proxy with the app's rules Bun's fetch picks a proxy by scheme only, so with just HTTP_PROXY set the https Secrets Manager request went direct and a proxy-only container still could not boot. Move proxy selection (HTTPS_PROXY falling back to HTTP_PROXY, curl-style NO_PROXY) from apps/sim into @sim/security/environment-proxy, and have runtime-secrets pass the selected proxy to Bun's fetch explicitly, or send through the Node handler when NO_PROXY excludes the endpoint. --- .../docs/platform/self-hosting/networking.mdx | 2 +- .../network/environment-proxy.server.test.ts | 2 +- .../lib/core/network/proxy-agent.server.ts | 8 +-- apps/sim/lib/core/network/transport.server.ts | 2 +- bun.lock | 2 + packages/runtime-secrets/package.json | 4 +- packages/runtime-secrets/src/index.ts | 58 ++++++++++++--- .../runtime-secrets/src/proxy-egress.test.ts | 72 +++++++++++-------- packages/security/package.json | 4 ++ .../security/src/environment-proxy.ts | 2 +- 10 files changed, 109 insertions(+), 47 deletions(-) rename apps/sim/lib/core/network/environment-proxy.server.ts => packages/security/src/environment-proxy.ts (98%) diff --git a/apps/docs/content/docs/platform/self-hosting/networking.mdx b/apps/docs/content/docs/platform/self-hosting/networking.mdx index 5e1daea44c8..0c21bd9b831 100644 --- a/apps/docs/content/docs/platform/self-hosting/networking.mdx +++ b/apps/docs/content/docs/platform/self-hosting/networking.mdx @@ -301,7 +301,7 @@ 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, 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 and, behind a proxy, the boot-time Secrets Manager fetch, applies Bun's own `NO_PROXY` rules, which may not honor CIDR or bare IPv6 entries, so list those destinations by hostname. +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: diff --git a/apps/sim/lib/core/network/environment-proxy.server.test.ts b/apps/sim/lib/core/network/environment-proxy.server.test.ts index ca4e1d7e45e..15c9ff59388 100644 --- a/apps/sim/lib/core/network/environment-proxy.server.test.ts +++ b/apps/sim/lib/core/network/environment-proxy.server.test.ts @@ -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, diff --git a/apps/sim/lib/core/network/proxy-agent.server.ts b/apps/sim/lib/core/network/proxy-agent.server.ts index bdf7c0968b5..de68381bc39 100644 --- a/apps/sim/lib/core/network/proxy-agent.server.ts +++ b/apps/sim/lib/core/network/proxy-agent.server.ts @@ -1,14 +1,14 @@ import http from 'node:http' import https from 'node:https' import { isIP } from 'node:net' +import { + isEnvironmentProxyConfigured, + resolveEnvironmentProxy, +} from '@sim/security/environment-proxy' import { NodeHttpHandler } from '@smithy/node-http-handler' import { Agent, type AgentConnectOpts } from 'agent-base' import { HttpProxyAgent } from 'http-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent' -import { - isEnvironmentProxyConfigured, - resolveEnvironmentProxy, -} from '@/lib/core/network/environment-proxy.server' /** Matches the AWS SDK's own default agent, so swapping it in changes routing and nothing else. */ const AGENT_OPTIONS: http.AgentOptions = { keepAlive: true, maxSockets: 50 } diff --git a/apps/sim/lib/core/network/transport.server.ts b/apps/sim/lib/core/network/transport.server.ts index afea63b2d3e..85d294565c1 100644 --- a/apps/sim/lib/core/network/transport.server.ts +++ b/apps/sim/lib/core/network/transport.server.ts @@ -1,6 +1,6 @@ +import { resolveEnvironmentProxy } from '@sim/security/environment-proxy' import { type Agent, type Dispatcher, ProxyAgent, request } from 'undici/index.js' import { resolveCurrentOutboundRoute } from '@/lib/core/network/context.server' -import { resolveEnvironmentProxy } from '@/lib/core/network/environment-proxy.server' import { createGatewayDispatcher } from '@/lib/core/network/gateway.server' import { OutboundRoutingError } from '@/lib/core/network/routing' import type { EgressProfile } from '@/lib/core/security/egress/profiles' diff --git a/bun.lock b/bun.lock index 25b6c332b17..ec60d0537a9 100644 --- a/bun.lock +++ b/bun.lock @@ -723,8 +723,10 @@ "dependencies": { "@aws-sdk/client-secrets-manager": "3.1150.0", "@sim/logger": "workspace:*", + "@sim/security": "workspace:*", "@sim/utils": "workspace:*", "@smithy/fetch-http-handler": "5.8.0", + "@smithy/node-http-handler": "4.12.1", }, "devDependencies": { "@sim/tsconfig": "workspace:*", diff --git a/packages/runtime-secrets/package.json b/packages/runtime-secrets/package.json index 5f61de7e43e..8be0b047139 100644 --- a/packages/runtime-secrets/package.json +++ b/packages/runtime-secrets/package.json @@ -31,8 +31,10 @@ "dependencies": { "@aws-sdk/client-secrets-manager": "3.1150.0", "@sim/logger": "workspace:*", + "@sim/security": "workspace:*", "@sim/utils": "workspace:*", - "@smithy/fetch-http-handler": "5.8.0" + "@smithy/fetch-http-handler": "5.8.0", + "@smithy/node-http-handler": "4.12.1" }, "devDependencies": { "@sim/tsconfig": "workspace:*", diff --git a/packages/runtime-secrets/src/index.ts b/packages/runtime-secrets/src/index.ts index e4c2028346a..bd89bf7995f 100644 --- a/packages/runtime-secrets/src/index.ts +++ b/packages/runtime-secrets/src/index.ts @@ -1,10 +1,15 @@ import type { GetSecretValueCommandOutput } from '@aws-sdk/client-secrets-manager' import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager' import { createLogger } from '@sim/logger' +import { + isEnvironmentProxyConfigured, + resolveEnvironmentProxy, +} from '@sim/security/environment-proxy' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' import { FetchHttpHandler } from '@smithy/fetch-http-handler' +import { NodeHttpHandler } from '@smithy/node-http-handler' const logger = createLogger('RuntimeSecrets') @@ -71,20 +76,53 @@ export async function fetchRuntimeSecrets(secretId: string): Promise { + const url = input instanceof Request ? input.url : String(input) + const proxied: RequestInit & { proxy?: string } = { + ...init, + proxy: resolveEnvironmentProxy(new URL(url)) ?? undefined, + } + return fetch(input, proxied) + }, + }) + } + + override handle(...args: Parameters) { + const [request, options] = args + const port = request.port ? `:${request.port}` : '' + const target = new URL(`${request.protocol}//${request.hostname}${port}`) + if (resolveEnvironmentProxy(target)) return super.handle(request, options) + return this.direct.handle(request, options) + } + + override destroy() { + this.direct.destroy() + super.destroy() + } +} /** * The SDK's default Node handler ignores `HTTPS_PROXY`, so a container whose only egress is a - * proxy could not fetch its config and would never boot. While a proxy is set under Bun (both - * container bootstraps run on Bun), requests go through Bun's `fetch`, which honors - * `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`. The app's `awsRequestHandler` lives in `apps/sim`, - * which packages cannot import, and the realtime bootstrap needs this path too. Undefined - * otherwise, leaving the SDK default in place. + * proxy could not fetch its config and would never boot. Both container bootstraps run on Bun, + * so while a proxy is set this returns an {@link EnvironmentProxyHandler}. The app's + * `awsRequestHandler` lives in `apps/sim`, which packages cannot import. Undefined otherwise, + * leaving the SDK default in place. */ -function proxyAwareRequestHandler(): FetchHttpHandler | undefined { - if (!process.versions.bun) return undefined - if (!PROXY_ENV.some((name) => process.env[name]?.trim())) return undefined - return new FetchHttpHandler() +function proxyAwareRequestHandler(): EnvironmentProxyHandler | undefined { + if (!process.versions.bun || !isEnvironmentProxyConfigured()) return undefined + return new EnvironmentProxyHandler() } async function fetchSecretString(client: SecretsManagerClient, secretId: string): Promise { diff --git a/packages/runtime-secrets/src/proxy-egress.test.ts b/packages/runtime-secrets/src/proxy-egress.test.ts index cde9087ab13..8215d0d5c08 100644 --- a/packages/runtime-secrets/src/proxy-egress.test.ts +++ b/packages/runtime-secrets/src/proxy-egress.test.ts @@ -3,7 +3,7 @@ import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' const tunnels: string[] = [] let proxy: Server @@ -20,39 +20,55 @@ beforeAll(async () => { proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}` }) +beforeEach(() => { + tunnels.length = 0 +}) + afterAll(async () => { proxy.closeAllConnections() await new Promise((resolve) => proxy.close(() => resolve())) }) +/** Runs `loadRuntimeSecrets` in a real Bun process against a Secrets Manager endpoint only the proxy could reach. */ +function loadUnderBun(proxyEnv: Record) { + const entry = fileURLToPath(new URL('./index.ts', import.meta.url)) + return promisify(execFile)( + 'bun', + ['--no-env-file', '-e', `await (await import(${JSON.stringify(entry)})).loadRuntimeSecrets()`], + { + timeout: 20_000, + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + SIM_ENV_SECRET_ID: '/test/sim/env-vars', + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY_ID: 'AKIDEXAMPLE', + AWS_SECRET_ACCESS_KEY: 'secret', + AWS_CONFIG_FILE: '/dev/null', + AWS_SHARED_CREDENTIALS_FILE: '/dev/null', + AWS_ENDPOINT_URL_SECRETS_MANAGER: 'https://secrets.invalid', + ...proxyEnv, + }, + } + ) +} + describe('loadRuntimeSecrets under a proxy-only egress', () => { - it('reaches Secrets Manager through HTTPS_PROXY under the Bun runtime', async () => { - const entry = fileURLToPath(new URL('./index.ts', import.meta.url)) - const run = promisify(execFile)( - 'bun', - [ - '--no-env-file', - '-e', - `await (await import(${JSON.stringify(entry)})).loadRuntimeSecrets()`, - ], - { - timeout: 20_000, - env: { - PATH: process.env.PATH, - HOME: process.env.HOME, - SIM_ENV_SECRET_ID: '/test/sim/env-vars', - AWS_REGION: 'us-east-1', - AWS_ACCESS_KEY_ID: 'AKIDEXAMPLE', - AWS_SECRET_ACCESS_KEY: 'secret', - AWS_CONFIG_FILE: '/dev/null', - AWS_SHARED_CREDENTIALS_FILE: '/dev/null', - AWS_ENDPOINT_URL_SECRETS_MANAGER: 'https://secrets.invalid', - HTTPS_PROXY: proxyUrl, - NO_PROXY: '', - }, - } + it.each(['HTTPS_PROXY', 'HTTP_PROXY'])( + 'reaches Secrets Manager through %s under the Bun runtime', + async (name) => { + await expect(loadUnderBun({ [name]: proxyUrl })).rejects.toThrow( + /Failed to fetch runtime secrets/ + ) + expect(tunnels).toContain('secrets.invalid:443') + }, + 30_000 + ) + + it('connects directly when NO_PROXY names the Secrets Manager host', async () => { + await expect(loadUnderBun({ HTTP_PROXY: proxyUrl, NO_PROXY: '.invalid' })).rejects.toThrow( + /Failed to fetch runtime secrets/ ) - await expect(run).rejects.toThrow(/Failed to fetch runtime secrets/) - expect(tunnels).toContain('secrets.invalid:443') + expect(tunnels).toEqual([]) }, 30_000) }) diff --git a/packages/security/package.json b/packages/security/package.json index 5d20bd94efd..b6931864745 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -26,6 +26,10 @@ "types": "./src/encryption.ts", "default": "./src/encryption.ts" }, + "./environment-proxy": { + "types": "./src/environment-proxy.ts", + "default": "./src/environment-proxy.ts" + }, "./hash": { "types": "./src/hash.ts", "default": "./src/hash.ts" diff --git a/apps/sim/lib/core/network/environment-proxy.server.ts b/packages/security/src/environment-proxy.ts similarity index 98% rename from apps/sim/lib/core/network/environment-proxy.server.ts rename to packages/security/src/environment-proxy.ts index f7649425f6b..bfd9c6bbfa9 100644 --- a/apps/sim/lib/core/network/environment-proxy.server.ts +++ b/packages/security/src/environment-proxy.ts @@ -1,5 +1,5 @@ import { BlockList, isIP } from 'node:net' -import { unwrapIpv6Brackets } from '@sim/security/hostnames' +import { unwrapIpv6Brackets } from './hostnames' const DEFAULT_PORTS: Record = { 'http:': 80, 'https:': 443 } From 87fc20820d8f01367bb617760e5c3fc13b2f3f7d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 16:12:31 -0800 Subject: [PATCH 15/15] docs(api): state the credential and role a manual v2 run requires Manual runs need api:write, which read-only personal keys lack, workspace write access, and a personal key or OAuth token; workspace API keys are refused. Regenerate the OpenAPI document and the MCP operation catalog. --- apps/docs/openapi-v2-workflows.json | 2 +- apps/sim/lib/api/contracts/v2/openapi/workflows.ts | 2 +- apps/sim/lib/api/mcp/generated/v2-operations.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 4654bef2ae1..f0b30c41413 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -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 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 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"], diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index cc841da6ab3..e077ab772b4 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -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 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 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', diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 4111ad8de84..e653f37b816 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -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 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 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), },