From 075599a418c1b2814221e95feb796d35ac8d2ef8 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 1 Aug 2026 18:50:03 -0700 Subject: [PATCH 1/8] fix(copilot): secrets injection into sandbox --- .../content/docs/en/platform/credentials.mdx | 6 +- .../app/api/mothership/execute/route.test.ts | 30 +- apps/sim/app/api/mothership/execute/route.ts | 21 +- .../resolved-secret-content-projection.ts | 411 ++++++++++++++++++ apps/sim/lib/copilot/chat/post.test.ts | 16 +- apps/sim/lib/copilot/chat/post.ts | 8 +- .../lib/copilot/environment-context.test.ts | 53 +++ apps/sim/lib/copilot/environment-context.ts | 42 ++ .../copilot/request/handlers/handlers.test.ts | 64 ++- .../lib/copilot/request/lifecycle/run.test.ts | 22 +- apps/sim/lib/copilot/request/lifecycle/run.ts | 15 +- .../sim/lib/copilot/request/tools/executor.ts | 38 +- .../tools/resolved-secret-result.test.ts | 204 +++++++++ .../request/tools/resolved-secret-result.ts | 90 ++++ .../sim/lib/copilot/tools/handlers/context.ts | 13 +- .../logs/execution/trace-secret-projection.ts | 272 ++---------- apps/sim/tools/index.test.ts | 5 +- 17 files changed, 1017 insertions(+), 293 deletions(-) create mode 100644 apps/sim/executor/utils/resolved-secret-content-projection.ts create mode 100644 apps/sim/lib/copilot/environment-context.test.ts create mode 100644 apps/sim/lib/copilot/environment-context.ts create mode 100644 apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts create mode 100644 apps/sim/lib/copilot/request/tools/resolved-secret-result.ts diff --git a/apps/docs/content/docs/en/platform/credentials.mdx b/apps/docs/content/docs/en/platform/credentials.mdx index 824fa951318..e3f8bf94049 100644 --- a/apps/docs/content/docs/en/platform/credentials.mdx +++ b/apps/docs/content/docs/en/platform/credentials.mdx @@ -75,6 +75,10 @@ This is an observability projection only. Secret resolution and workflow behavio Masking is activated only when Sim successfully resolves a value from **Settings → Secrets** through `{{KEY}}`. A hardcoded literal, direct `environmentVariables['KEY']` read, or shell `$KEY` read does not activate it by itself. Once activated, every exact occurrence of that value in the run's log-facing content is masked. Encoded, hashed, or otherwise transformed versions are not matched. Do not deliberately return or print secrets. +### Copilot code execution + +When Copilot runs its built-in Function or code-execution tool, the sandbox still receives the real value for a successful `{{KEY}}` substitution. Before the tool result is returned to Copilot, exact occurrences of that activated value are replaced with `{{KEY}}`. This keeps the plaintext out of Copilot's tool-result context without changing the code that ran or its local runtime result. If Sim cannot verify the execution's secret provenance, it omits the result content instead of returning it unverified. Hardcoded, directly read, encoded, hashed, and otherwise transformed values follow the same limitations described above. + ## Secret Details Click **Details** on any secret row to open its detail view. @@ -122,7 +126,7 @@ When a workflow runs, secrets resolve in this order: { } function activateSecret(options: CopilotLifecycleOptions): void { - options.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'secret-value') + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? options.resolvedSecretTraceRegistry + registry?.recordResolved('API_KEY', 'secret-value') } it('does not expose private provenance unless the internal caller requests it', async () => { @@ -218,7 +220,7 @@ describe('mothership private trace provenance transport', () => { expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled() expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ resolvedSecretTraceRegistry: undefined }) + expect.objectContaining({ environmentContext: undefined }) ) }) @@ -227,6 +229,7 @@ describe('mothership private trace provenance transport', () => { mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { expect(options.resolvedSecretTraceRegistry?.isComplete()).toBe(false) + expect(options.environmentContext).toBeUndefined() return successResult() } ) @@ -258,9 +261,10 @@ describe('mothership private trace provenance transport', () => { it('fails provenance closed without changing a runtime value that rotated after catalog load', async () => { mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { - expect( - options.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'rotated-secret-value') - ).toBe(false) + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? + options.resolvedSecretTraceRegistry + expect(registry?.recordResolved('API_KEY', 'rotated-secret-value')).toBe(false) return { ...successResult(), content: 'rotated-secret-value' } } ) @@ -292,6 +296,11 @@ describe('mothership private trace provenance transport', () => { it('returns encrypted provenance on a marker-gated successful request', async () => { mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { + expect(options.environmentContext?.decryptedEnvVars).toEqual({ + API_KEY: 'secret-value', + }) + expect(options.environmentContext?.resolvedSecretTraceRegistry).toBeDefined() + expect(options.resolvedSecretTraceRegistry).toBeUndefined() activateSecret(options) return successResult() } @@ -322,6 +331,7 @@ describe('mothership private trace provenance transport', () => { scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) expect(JSON.stringify(body.__resolvedSecretTraceProvenance)).not.toContain('secret-value') + expect(mockGetPersonalAndWorkspaceEnv).toHaveBeenCalledTimes(1) }) it('imports MCP schema-discovery provenance before starting the lifecycle', async () => { @@ -344,7 +354,10 @@ describe('mothership private trace provenance transport', () => { ) mockRunHeadlessCopilotLifecycle.mockImplementation( async (payload: Record, options: CopilotLifecycleOptions) => { - expect(options.resolvedSecretTraceRegistry?.exportProvenance()).toEqual(provenance) + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? + options.resolvedSecretTraceRegistry + expect(registry?.exportProvenance()).toEqual(provenance) expect(JSON.stringify(payload)).not.toContain('encrypted-secret') expect(JSON.stringify(payload)).not.toContain('__resolvedSecretTraceProvenance') return successResult() @@ -388,7 +401,10 @@ describe('mothership private trace provenance transport', () => { ) mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { - expect(options.resolvedSecretTraceRegistry?.isComplete()).toBe(false) + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? + options.resolvedSecretTraceRegistry + expect(registry?.isComplete()).toBe(false) return successResult() } ) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 1febf5e513e..f12242538c2 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -10,6 +10,10 @@ import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { processContextsServer } from '@/lib/copilot/chat/process-contents' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' +import { + type CopilotEnvironmentContext, + createCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, @@ -33,7 +37,6 @@ import { } from '@/lib/workspaces/permissions/utils' import { createIncompleteResolvedSecretTraceRegistry, - createResolvedSecretTraceRegistry, ResolvedSecretTraceProvenanceAccumulator, type ResolvedSecretTraceRegistry, } from '@/executor/utils/resolved-secret-trace-registry' @@ -135,6 +138,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { let messageId: string | undefined let requestId: string | undefined let resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined + let environmentContext: CopilotEnvironmentContext | undefined const includePrivateProvenance = requestsPrivateToolMetadata( req.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1 @@ -192,14 +196,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const environment = await getPersonalAndWorkspaceEnv(userId, workspaceId, { workspaceAccess, }) - resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({ - personalEncrypted: environment.personalEncrypted, - workspaceEncrypted: environment.workspaceEncrypted, - personalDecrypted: environment.personalDecrypted, - workspaceDecrypted: environment.workspaceDecrypted, - decryptionFailures: environment.decryptionFailures, - scope, - }) + environmentContext = await createCopilotEnvironmentContext(userId, workspaceId, environment) + resolvedSecretTraceRegistry = environmentContext.resolvedSecretTraceRegistry } catch (error) { logger.warn('Failed to build Mothership trace secret catalog', { error: getErrorMessage(error), @@ -376,7 +374,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { interactive: false, abortSignal: lifecycleAbortController.signal, billingAttribution, - resolvedSecretTraceRegistry, + environmentContext, + ...(!environmentContext && resolvedSecretTraceRegistry + ? { resolvedSecretTraceRegistry } + : {}), onEvent, }) diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.ts b/apps/sim/executor/utils/resolved-secret-content-projection.ts new file mode 100644 index 00000000000..8b6ba0d2370 --- /dev/null +++ b/apps/sim/executor/utils/resolved-secret-content-projection.ts @@ -0,0 +1,411 @@ +import { isPlainRecord } from '@sim/utils/object' +import { LARGE_ARRAY_MANIFEST_MARKER } from '@/lib/execution/payloads/large-array-manifest-metadata' +import { LARGE_VALUE_REF_MARKER } from '@/lib/execution/payloads/large-value-ref' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/materialization.server' +import type { ResolvedSecretTraceMatch } from '@/executor/utils/resolved-secret-trace-registry' + +const MAX_CONTENT_NODES = 100_000 +const MAX_CONTENT_DEPTH = 100 +const MAX_MATCHER_NODES = 250_000 +const MAX_SECRET_LITERAL_LENGTH = 64 * 1024 +const MAX_MATCH_EVENTS = 1_000_000 + +interface SecretReplacement { + plaintext: string + replacement: string +} + +interface SecretTrieNode { + children: Map + failure?: SecretTrieNode + outputLink?: SecretTrieNode + replacement?: SecretReplacement +} + +export interface ResolvedSecretMatcher { + root: SecretTrieNode + maxPatternLength: number +} + +interface ProjectionState { + nodes: number + ancestors: WeakSet + outputBytes: number + maxBytes: number +} + +export type ResolvedSecretContentProjection = { safe: true; value: unknown } | { safe: false } + +class ResolvedSecretContentProjectionError extends Error { + constructor(message: string) { + super(message) + this.name = 'ResolvedSecretContentProjectionError' + } +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +function createMatcherFromReplacements( + replacements: readonly SecretReplacement[] +): ResolvedSecretMatcher { + const root: SecretTrieNode = { children: new Map() } + root.failure = root + let nodeCount = 1 + let maxPatternLength = 0 + + for (const replacement of replacements) { + if (replacement.plaintext.length > MAX_SECRET_LITERAL_LENGTH) { + throw new ResolvedSecretContentProjectionError( + 'Secret literal exceeds the matcher size limit' + ) + } + maxPatternLength = Math.max(maxPatternLength, replacement.plaintext.length) + let node = root + for (let index = 0; index < replacement.plaintext.length; index += 1) { + const character = replacement.plaintext[index] + let child = node.children.get(character) + if (!child) { + child = { children: new Map() } + node.children.set(character, child) + nodeCount += 1 + if (nodeCount > MAX_MATCHER_NODES) { + throw new ResolvedSecretContentProjectionError('Secret matcher node limit exceeded') + } + } + node = child + } + node.replacement = replacement + } + + const queue: SecretTrieNode[] = [] + for (const child of root.children.values()) { + child.failure = root + queue.push(child) + } + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const node = queue[cursor] + for (const [character, child] of node.children) { + let fallback = node.failure ?? root + while (fallback !== root && !fallback.children.has(character)) { + fallback = fallback.failure ?? root + } + const transition = fallback.children.get(character) + child.failure = transition && transition !== child ? transition : root + child.outputLink = child.failure.replacement ? child.failure : child.failure.outputLink + queue.push(child) + } + } + + return { root, maxPatternLength } +} + +function advanceMatcher( + matcher: ResolvedSecretMatcher, + node: SecretTrieNode, + character: string +): SecretTrieNode { + let current = node + while (current !== matcher.root && !current.children.has(character)) { + current = current.failure ?? matcher.root + } + return current.children.get(character) ?? matcher.root +} + +export function containsResolvedSecret(value: string, matcher: ResolvedSecretMatcher): boolean { + let node = matcher.root + for (let index = 0; index < value.length; index += 1) { + node = advanceMatcher(matcher, node, value[index]) + if (node.replacement || node.outputLink) return true + } + return false +} + +export function sanitizeResolvedSecretString( + value: string, + matcher: ResolvedSecretMatcher, + maxBytes = MAX_INLINE_MATERIALIZATION_BYTES +): string { + if (maxBytes < 0) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized secret-bearing string exceeds the size limit' + ) + } + if (Buffer.byteLength(value, 'utf8') > maxBytes) { + throw new ResolvedSecretContentProjectionError('Secret-bearing string exceeds the size limit') + } + if (matcher.maxPatternLength === 0 || value.length === 0) return value + + let emitCursor = 0 + let literalStart = 0 + let outputBytes = 0 + let matchEvents = 0 + const chunks: string[] = [] + const windowSize = matcher.maxPatternLength + const slotStarts = new Int32Array(windowSize) + const slotEnds = new Int32Array(windowSize) + slotStarts.fill(-1) + const slotReplacements = new Array(windowSize) + + const append = (chunk: string): void => { + if (!chunk) return + outputBytes += Buffer.byteLength(chunk, 'utf8') + if (outputBytes > maxBytes) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized secret-bearing string exceeds the size limit' + ) + } + const lastIndex = chunks.length - 1 + if (lastIndex >= 0 && chunks[lastIndex].length + chunk.length <= 64 * 1024) { + chunks[lastIndex] += chunk + } else { + chunks.push(chunk) + } + } + + const finalizeThrough = (limit: number): void => { + while (emitCursor <= limit && emitCursor < value.length) { + const slot = emitCursor % windowSize + if (slotStarts[slot] === emitCursor && slotReplacements[slot] !== undefined) { + append(value.slice(literalStart, emitCursor)) + append(slotReplacements[slot] ?? '') + emitCursor = slotEnds[slot] + literalStart = emitCursor + } else { + emitCursor += 1 + } + } + } + + let node = matcher.root + for (let index = 0; index < value.length; index += 1) { + node = advanceMatcher(matcher, node, value[index]) + let outputNode: SecretTrieNode | undefined = node.replacement ? node : node.outputLink + while (outputNode?.replacement) { + matchEvents += 1 + if (matchEvents > MAX_MATCH_EVENTS) { + throw new ResolvedSecretContentProjectionError('Secret matcher event limit exceeded') + } + const start = index - outputNode.replacement.plaintext.length + 1 + if (start >= emitCursor) { + const slot = start % windowSize + const end = index + 1 + if (slotStarts[slot] !== start || end > slotEnds[slot]) { + slotStarts[slot] = start + slotEnds[slot] = end + slotReplacements[slot] = outputNode.replacement.replacement + } + } + outputNode = outputNode.outputLink + } + finalizeThrough(index - matcher.maxPatternLength + 1) + } + + finalizeThrough(value.length - 1) + append(value.slice(literalStart)) + const sanitized = chunks.join('') + if (containsResolvedSecret(sanitized, matcher)) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized content still contains an active secret' + ) + } + return sanitized +} + +export function createResolvedSecretMatcher( + matches: readonly ResolvedSecretTraceMatch[] +): ResolvedSecretMatcher | undefined { + const replacementByPlaintext = new Map() + + for (const match of matches) { + if (!match.plaintext) continue + const current = replacementByPlaintext.get(match.plaintext) + if (current === undefined || compareStrings(match.replacement, current) < 0) { + replacementByPlaintext.set(match.plaintext, match.replacement) + } + } + + const provisional = [...replacementByPlaintext.keys()] + .map((plaintext) => ({ + plaintext, + replacement: replacementByPlaintext.get(plaintext) ?? '', + })) + .sort( + (left, right) => + right.plaintext.length - left.plaintext.length || + compareStrings(left.replacement, right.replacement) || + compareStrings(left.plaintext, right.plaintext) + ) + + if (provisional.length === 0) return undefined + + const detector = createMatcherFromReplacements( + provisional.map(({ plaintext }) => ({ plaintext, replacement: '' })) + ) + return createMatcherFromReplacements( + provisional.map(({ plaintext, replacement }) => ({ + plaintext, + replacement: containsResolvedSecret(replacement, detector) ? '' : replacement, + })) + ) +} + +function visitNode(state: ProjectionState, depth: number): void { + state.nodes += 1 + if (state.nodes > MAX_CONTENT_NODES) { + throw new ResolvedSecretContentProjectionError('Secret-bearing content exceeds node limit') + } + if (depth > MAX_CONTENT_DEPTH) { + throw new ResolvedSecretContentProjectionError('Secret-bearing content exceeds depth limit') + } +} + +function* enumerableDataEntries(value: object): Generator<[string, unknown]> { + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') { + throw new ResolvedSecretContentProjectionError('Content cannot contain symbol properties') + } + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new ResolvedSecretContentProjectionError('Content accessors are not supported') + } + yield [key, descriptor.value] + } +} + +function* arrayDataEntries(value: readonly unknown[]): Generator<[number, unknown]> { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length') + if ( + !lengthDescriptor || + !('value' in lengthDescriptor) || + typeof lengthDescriptor.value !== 'number' || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 + ) { + throw new ResolvedSecretContentProjectionError('Content array length is invalid') + } + + for (const key of Reflect.ownKeys(value)) { + if (key === 'length') continue + if (typeof key !== 'string') { + throw new ResolvedSecretContentProjectionError('Content arrays cannot contain symbols') + } + const index = Number(key) + if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) { + throw new ResolvedSecretContentProjectionError('Content array has custom properties') + } + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new ResolvedSecretContentProjectionError('Content array accessors are unsupported') + } + yield [index, descriptor.value] + } +} + +function sanitizeContent( + value: unknown, + matcher: ResolvedSecretMatcher, + state: ProjectionState, + depth = 0 +): unknown { + visitNode(state, depth) + if (typeof value === 'string') { + const sanitized = sanitizeResolvedSecretString( + value, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitized, 'utf8') + return sanitized + } + if (value === null || typeof value === 'number' || typeof value === 'boolean') { + const rendered = String(value) + if (!containsResolvedSecret(rendered, matcher)) return value + const sanitized = sanitizeResolvedSecretString( + rendered, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitized, 'utf8') + return sanitized + } + if (value === undefined) return value + if (typeof value !== 'object' || (!Array.isArray(value) && !isPlainRecord(value))) { + throw new ResolvedSecretContentProjectionError('Unsupported secret-bearing content value') + } + if ( + !Array.isArray(value) && + (Object.hasOwn(value, LARGE_VALUE_REF_MARKER) || + Object.hasOwn(value, LARGE_ARRAY_MANIFEST_MARKER)) + ) { + throw new ResolvedSecretContentProjectionError( + 'Offloaded secret-bearing content cannot cross this boundary' + ) + } + if (state.ancestors.has(value)) { + throw new ResolvedSecretContentProjectionError('Cyclic secret-bearing content is unsupported') + } + + state.ancestors.add(value) + try { + if (Array.isArray(value)) { + if (value.length > MAX_CONTENT_NODES - state.nodes) { + throw new ResolvedSecretContentProjectionError('Content array exceeds traversal limit') + } + const sanitized = new Array(value.length) + for (const [index, item] of arrayDataEntries(value)) { + sanitized[index] = sanitizeContent(item, matcher, state, depth + 1) + } + return sanitized + } + + const sanitized = Object.create(Object.getPrototypeOf(value)) as Record + const sanitizedKeys = new Set() + for (const [key, item] of enumerableDataEntries(value)) { + const sanitizedKey = sanitizeResolvedSecretString( + key, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitizedKey, 'utf8') + if (sanitizedKeys.has(sanitizedKey)) { + throw new ResolvedSecretContentProjectionError( + 'Secret replacement caused an object-key collision' + ) + } + sanitizedKeys.add(sanitizedKey) + Object.defineProperty(sanitized, sanitizedKey, { + value: sanitizeContent(item, matcher, state, depth + 1), + enumerable: true, + configurable: true, + writable: true, + }) + } + return sanitized + } finally { + state.ancestors.delete(value) + } +} + +export function projectResolvedSecretContent( + value: unknown, + matcher: ResolvedSecretMatcher, + maxBytes = MAX_INLINE_MATERIALIZATION_BYTES +): ResolvedSecretContentProjection { + try { + return { + safe: true, + value: sanitizeContent(value, matcher, { + nodes: 0, + ancestors: new WeakSet(), + outputBytes: 0, + maxBytes, + }), + } + } catch { + return { safe: false } + } +} diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 1de5ea17dcb..a15680a5012 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -14,11 +14,12 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const resolveWorkflowIdForUser = workflowsUtilsMockFns.mockResolveWorkflowIdForUser const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -const getEffectiveDecryptedEnv = environmentUtilsMockFns.mockGetEffectiveDecryptedEnv +const getEffectiveEnvironmentSnapshot = environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot const { generateWorkspaceSnapshot, @@ -134,7 +135,14 @@ describe('handleUnifiedChatPost', () => { }) getUserEntityPermissions.mockResolvedValue('write') resolveBillingAttribution.mockResolvedValue(billingAttribution) - getEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'secret' }) + getEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: { API_KEY: 'encrypted-secret' }, + workspaceEncrypted: {}, + personalDecrypted: { API_KEY: 'secret' }, + workspaceDecrypted: {}, + conflicts: [], + decryptionFailures: [], + }) generateWorkspaceSnapshot.mockResolvedValue({ markdown: 'workspace context', snapshot: { workflows: [{ id: 'wf-1', name: 'Alpha', path: 'workflows/Alpha' }] }, @@ -197,6 +205,8 @@ describe('handleUnifiedChatPost', () => { workspaceId: 'ws-1', billingAttribution, requestMode: 'agent', + decryptedEnvVars: { API_KEY: 'secret' }, + resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), }), }), }) @@ -238,6 +248,8 @@ describe('handleUnifiedChatPost', () => { workspaceId: 'ws-1', billingAttribution, requestMode: 'agent', + decryptedEnvVars: { API_KEY: 'secret' }, + resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), }), }), }) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 4b802c1f27f..27855a7d911 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -32,6 +32,7 @@ import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' +import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { CopilotChatFinalizeOutcome, CopilotChatPersistOutcome, @@ -52,7 +53,6 @@ import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request import { persistChatResources } from '@/lib/copilot/resources/persistence' import { isEphemeralResource } from '@/lib/copilot/resources/types' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { captureServerEvent } from '@/lib/posthog/server' import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { @@ -561,8 +561,8 @@ async function buildInitialExecutionContext(params: { } } - const [decryptedEnvVars, billingAttribution] = await Promise.all([ - getEffectiveDecryptedEnv(userId, workspaceId), + const [environmentContext, billingAttribution] = await Promise.all([ + prepareCopilotEnvironmentContext(userId, workspaceId), workspaceId ? resolveBillingAttribution({ actorUserId: userId, workspaceId }) : Promise.resolve(undefined), @@ -572,7 +572,7 @@ async function buildInitialExecutionContext(params: { workflowId: workflowId ?? '', workspaceId, chatId, - decryptedEnvVars, + ...environmentContext, billingAttribution, messageId, userTimezone, diff --git a/apps/sim/lib/copilot/environment-context.test.ts b/apps/sim/lib/copilot/environment-context.test.ts new file mode 100644 index 00000000000..b7d8038a855 --- /dev/null +++ b/apps/sim/lib/copilot/environment-context.test.ts @@ -0,0 +1,53 @@ +/** + * @vitest-environment node + */ +import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' + +describe('prepareCopilotEnvironmentContext', () => { + afterEach(() => { + resetEnvironmentUtilsMock() + }) + + it('builds runtime env and secret provenance from one workspace-over-personal snapshot', async () => { + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: { + SHARED_SECRET: 'personal-encrypted', + PERSONAL_ONLY: 'personal-only-encrypted', + }, + workspaceEncrypted: { + SHARED_SECRET: 'workspace-encrypted', + WORKSPACE_ONLY: 'workspace-only-encrypted', + }, + personalDecrypted: { + SHARED_SECRET: 'personal-value', + PERSONAL_ONLY: 'personal-only-value', + }, + workspaceDecrypted: { + SHARED_SECRET: 'workspace-value', + WORKSPACE_ONLY: 'workspace-only-value', + }, + conflicts: ['SHARED_SECRET'], + decryptionFailures: [], + }) + + const context = await prepareCopilotEnvironmentContext('user-1', 'workspace-1') + + expect(context.decryptedEnvVars).toEqual({ + SHARED_SECRET: 'workspace-value', + PERSONAL_ONLY: 'personal-only-value', + WORKSPACE_ONLY: 'workspace-only-value', + }) + expect(context.resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect( + context.resolvedSecretTraceRegistry.recordResolved('SHARED_SECRET', 'workspace-value') + ).toBe(true) + expect(context.resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'workspace-value', replacement: '{{SHARED_SECRET}}' }, + ]) + expect( + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot + ).toHaveBeenCalledExactlyOnceWith('user-1', 'workspace-1') + }) +}) diff --git a/apps/sim/lib/copilot/environment-context.ts b/apps/sim/lib/copilot/environment-context.ts new file mode 100644 index 00000000000..4cd7c763e31 --- /dev/null +++ b/apps/sim/lib/copilot/environment-context.ts @@ -0,0 +1,42 @@ +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { + type EnvironmentResolutionSnapshot, + getEffectiveEnvironmentSnapshot, +} from '@/lib/environment/utils' +import { createResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export type CopilotEnvironmentContext = Pick< + ExecutionContext, + 'decryptedEnvVars' | 'resolvedSecretTraceRegistry' +> + +export async function createCopilotEnvironmentContext( + userId: string, + workspaceId: string | undefined, + environment: EnvironmentResolutionSnapshot +): Promise { + const resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({ + personalEncrypted: environment.personalEncrypted, + workspaceEncrypted: environment.workspaceEncrypted, + personalDecrypted: environment.personalDecrypted, + workspaceDecrypted: environment.workspaceDecrypted, + decryptionFailures: environment.decryptionFailures, + scope: { userId, workspaceId }, + }) + + return { + decryptedEnvVars: { + ...environment.personalDecrypted, + ...environment.workspaceDecrypted, + }, + resolvedSecretTraceRegistry, + } +} + +export async function prepareCopilotEnvironmentContext( + userId: string, + workspaceId?: string +): Promise { + const environment = await getEffectiveEnvironmentSnapshot(userId, workspaceId) + return createCopilotEnvironmentContext(userId, workspaceId, environment) +} diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index fb595791464..c5ad84af249 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -70,13 +70,14 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' -import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { prePersistClientExecutableToolCall, sseHandlers, subAgentHandlers, } from '@/lib/copilot/request/handlers' import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' describe('sse-handlers tool lifecycle', () => { let context: StreamingContext @@ -431,6 +432,67 @@ describe('sse-handlers tool lifecycle', () => { expect(updated?.result?.output).toBe('done') }) + it('projects resolved Function secrets before every Copilot-visible result sink', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ]) + registry.recordResolved('SECRET', 'secret-value') + execContext.resolvedSecretTraceRegistry = registry + executeTool.mockResolvedValueOnce({ + success: true, + output: { + result: 'secret-value', + stdout: 'prefix secret-value', + }, + }) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-function', + toolName: FunctionExecute.id, + arguments: { code: 'return {{SECRET}}' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: false, timeout: 1000 } + ) + + await sleep(0) + + const safeOutput = { + result: '{{SECRET}}', + stdout: 'prefix {{SECRET}}', + } + expect(completeAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolCallId: 'tool-function', + result: safeOutput, + }) + ) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + toolCallId: 'tool-function', + output: safeOutput, + }), + }) + ) + expect(context.toolCalls.get('tool-function')?.result?.output).toEqual(safeOutput) + expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('secret-value') + }) + it('marks background client workflow tools delivered after synthetic result emission', async () => { waitForToolCompletion.mockResolvedValueOnce({ status: 'background', diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 46cfd2f19c2..cdfab8dbaf4 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -2,18 +2,11 @@ * @vitest-environment node */ -import { - environmentUtilsMockFns, - resetEnvFlagsMock, - resetEnvironmentUtilsMock, - setEnvFlags, -} from '@sim/testing' +import { resetEnvFlagsMock, resetEnvironmentUtilsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -const mockGetEffectiveDecryptedEnv = environmentUtilsMockFns.mockGetEffectiveDecryptedEnv - afterAll(resetEnvironmentUtilsMock) const { @@ -21,6 +14,7 @@ const { mockForceFailHungToolCall, mockGetMothershipBaseURL, mockGetMothershipSourceEnvHeaders, + mockPrepareCopilotEnvironmentContext, mockPrepareExecutionContext, mockRunStreamLoop, mockPendingToolWaitBudgetMs, @@ -32,6 +26,7 @@ const { mockForceFailHungToolCall: vi.fn(), mockGetMothershipBaseURL: vi.fn(), mockGetMothershipSourceEnvHeaders: vi.fn(), + mockPrepareCopilotEnvironmentContext: vi.fn(), mockPrepareExecutionContext: vi.fn(), mockRunStreamLoop: vi.fn(), mockPendingToolWaitBudgetMs: vi.fn(() => 60_000), @@ -108,6 +103,10 @@ vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ addChatAutoAllowedTool: vi.fn(), })) +vi.mock('@/lib/copilot/environment-context', () => ({ + prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext, +})) + vi.mock('@/lib/copilot/tools/handlers/context', () => ({ prepareExecutionContext: mockPrepareExecutionContext, })) @@ -147,6 +146,7 @@ describe('runCopilotLifecycle', () => { mockGetAutoAllowedTools.mockResolvedValue(new Set()) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) + mockPrepareCopilotEnvironmentContext.mockResolvedValue({ decryptedEnvVars: {} }) }) it('threads trace provenance through server execution context only', async () => { @@ -482,7 +482,6 @@ describe('runCopilotLifecycle', () => { it('propagates payload userPermission into the generated execution context', async () => { let capturedExecContext: ExecutionContext | undefined - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async ( _fetchUrl: string, @@ -529,7 +528,6 @@ describe('runCopilotLifecycle', () => { setEnvFlags({ isHosted: true }) setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async ( _fetchUrl: string, @@ -591,7 +589,6 @@ describe('runCopilotLifecycle', () => { it('runs legacy-v0 during Sim-first deployment without guessed billing aliases', async () => { setEnvFlags({ isHosted: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1' }, @@ -626,7 +623,6 @@ describe('runCopilotLifecycle', () => { it('runs modern hosted work without legacy compatibility storage', async () => { setEnvFlags({ isHosted: true }) setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1' }, @@ -654,7 +650,6 @@ describe('runCopilotLifecycle', () => { it('does not emit trusted billing headers for a non-hosted lifecycle', async () => { mockEnv.COPILOT_API_KEY = 'user-or-self-hosted-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1', billingRequestId: 'caller-controlled' }, @@ -685,7 +680,6 @@ describe('runCopilotLifecycle', () => { it('normalizes the initial request body with workspaceId from lifecycle options', async () => { let requestBody: Record | undefined - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async (_fetchUrl: string, fetchOptions: RequestInit): Promise => { requestBody = JSON.parse(String(fetchOptions.body)) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 6eb5888b630..5d61c8f6b3d 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -12,6 +12,10 @@ import { import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_VERSION, TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/copilot/constants' +import { + type CopilotEnvironmentContext, + prepareCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' import { COPILOT_BILLING_PROTOCOL, COPILOT_BILLING_PROTOCOL_HEADER, @@ -61,7 +65,6 @@ import { isCopilotToolPermissionsEnabled, isHosted, } from '@/lib/core/config/env-flags' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('CopilotLifecycle') @@ -97,6 +100,7 @@ export interface CopilotLifecycleOptions extends OrchestratorOptions { executionContext?: ExecutionContext billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + environmentContext?: CopilotEnvironmentContext } /** @@ -183,6 +187,7 @@ export async function runCopilotLifecycle( abortSignal: lifecycleOptions.abortSignal, billingAttribution: lifecycleOptions.billingAttribution, resolvedSecretTraceRegistry: lifecycleOptions.resolvedSecretTraceRegistry, + environmentContext: lifecycleOptions.environmentContext, })) const shouldUseHostedBillingProtocol = isHosted && isCopilotBillingAttributionV1Enabled if ( @@ -1000,6 +1005,7 @@ async function buildExecutionContext( abortSignal?: AbortSignal billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + environmentContext?: CopilotEnvironmentContext } ): Promise { const { @@ -1012,6 +1018,7 @@ async function buildExecutionContext( abortSignal, billingAttribution, resolvedSecretTraceRegistry, + environmentContext, } = params const userTimezone = typeof requestPayload?.userTimezone === 'string' ? requestPayload.userTimezone : undefined @@ -1024,15 +1031,17 @@ async function buildExecutionContext( execContext = await prepareExecutionContext(userId, workflowId, chatId, { workspaceId, billingAttribution, + environmentContext, }) } else { - const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId) + const activeEnvironmentContext = + environmentContext ?? (await prepareCopilotEnvironmentContext(userId, workspaceId)) execContext = { userId, workflowId: '', workspaceId, chatId, - decryptedEnvVars, + ...activeEnvironmentContext, billingAttribution, } } diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index fd59ba2fa8b..7bd75dda22e 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -53,6 +53,7 @@ import { setTerminalToolCallState, } from '@/lib/copilot/request/tool-call-state' import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files' +import { projectFunctionResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' import { maybeWriteOutputToTable, @@ -558,6 +559,11 @@ async function executeToolAndReportInner( return terminalCompletionFromToolCall(toolCall) } if (abortRequested(context, execContext, options)) { + const copilotResult = projectFunctionResultForCopilot( + toolCall.name, + result, + execContext.resolvedSecretTraceRegistry + ) markToolCallCancelled('Request aborted during tool execution') markToolResultSeen(toolCall.id) await completeAsyncToolCall({ @@ -579,7 +585,7 @@ async function executeToolAndReportInner( }) endToolSpan('cancelled', { cancelReason: 'abort_during_execution', - error: result.success === false ? result.error : undefined, + error: copilotResult.success === false ? copilotResult.error : undefined, }) return cancelledCompletion('Request aborted during tool execution') } @@ -655,17 +661,23 @@ async function executeToolAndReportInner( endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' }) return cancelledCompletion('Request aborted during tool post-processing') } + const copilotResult = projectFunctionResultForCopilot( + toolCall.name, + result, + execContext.resolvedSecretTraceRegistry + ) + toolSpan.attributes = { ...toolSpan.attributes, - ...summarizeToolResultForSpan(result), + ...summarizeToolResultForSpan(copilotResult), } setTerminalToolCallState(toolCall, { - status: result.success + status: copilotResult.success ? MothershipStreamV1ToolOutcome.success : MothershipStreamV1ToolOutcome.error, - ...(hasOutputValue(result) ? { output: result.output } : {}), - ...(result.success ? {} : { error: result.error || 'Tool failed' }), + ...(hasOutputValue(copilotResult) ? { output: copilotResult.output } : {}), + ...(copilotResult.success ? {} : { error: copilotResult.error || 'Tool failed' }), }) if (result.success) { @@ -688,7 +700,7 @@ async function executeToolAndReportInner( logger.warn('Tool execution failed', { toolCallId: toolCall.id, toolName: toolCall.name, - error: result.error, + error: copilotResult.error, params: toolCall.params, }) } @@ -741,7 +753,7 @@ async function executeToolAndReportInner( mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.result, success: result.success, - output: result.output, + output: copilotResult.output, ...(result.success ? { status: MothershipStreamV1ToolOutcome.success } : { status: MothershipStreamV1ToolOutcome.error }), @@ -776,6 +788,12 @@ async function executeToolAndReportInner( }) } catch (error) { const thrownMessage = toError(error).message + const copilotError = projectFunctionResultForCopilot( + toolCall.name, + { success: false, error: thrownMessage }, + execContext.resolvedSecretTraceRegistry + ) + const safeThrownMessage = copilotError.error || 'Tool failed' if (abortRequested(context, execContext, options)) { markToolCallCancelled('Request aborted during tool execution') markToolResultSeen(toolCall.id) @@ -798,13 +816,13 @@ async function executeToolAndReportInner( }) endToolSpan('cancelled', { cancelReason: 'abort_during_execution_catch', - error: thrownMessage, + error: safeThrownMessage, }) return cancelledCompletion('Request aborted during tool execution') } setTerminalToolCallState(toolCall, { status: MothershipStreamV1ToolOutcome.error, - error: thrownMessage, + error: safeThrownMessage, }) logger.error('Tool execution threw', { @@ -848,7 +866,7 @@ async function executeToolAndReportInner( }, } await options?.onEvent?.(errorEvent) - endToolSpan('error', { error: thrownMessage }) + endToolSpan('error', { error: safeThrownMessage }) return buildCompletionSignal({ status: MothershipStreamV1ToolOutcome.error, message: toolCall.error, diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts new file mode 100644 index 00000000000..6b96267ef9e --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -0,0 +1,204 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { FunctionExecute, Read, RunCode } from '@/lib/copilot/generated/tool-catalog-v1' +import { + FUNCTION_RESULT_OMITTED_ERROR, + projectFunctionResultForCopilot, +} from '@/lib/copilot/request/tools/resolved-secret-result' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +function createRegistry(): ResolvedSecretTraceRegistry { + return new ResolvedSecretTraceRegistry([ + { + name: 'SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ]) +} + +describe('projectFunctionResultForCopilot', () => { + it.each([FunctionExecute.id, RunCode.id])( + 'projects active exact and embedded secrets for %s without mutating runtime output', + (toolName) => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const runtimeResult = { + success: true, + output: { + result: 'secret-value', + stdout: 'prefix-secret-value-suffix', + values: ['safe', 'secret-value'], + }, + } + const runtimeSnapshot = structuredClone(runtimeResult) + + expect(projectFunctionResultForCopilot(toolName, runtimeResult, registry)).toEqual({ + success: true, + output: { + result: '{{SECRET}}', + stdout: 'prefix-{{SECRET}}-suffix', + values: ['safe', '{{SECRET}}'], + }, + }) + expect(runtimeResult).toEqual(runtimeSnapshot) + } + ) + + it('projects both output and error from a failed Function execution', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + + expect( + projectFunctionResultForCopilot( + FunctionExecute.id, + { + success: false, + output: { stdout: 'printed secret-value' }, + error: 'Function failed near secret-value', + }, + registry + ) + ).toEqual({ + success: false, + output: { stdout: 'printed {{SECRET}}' }, + error: 'Function failed near {{SECRET}}', + }) + }) + + it('projects secret-bearing object keys and omits content when replacement collides', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + + expect( + projectFunctionResultForCopilot( + FunctionExecute.id, + { + success: true, + output: { 'prefix-secret-value': 'safe' }, + }, + registry + ) + ).toEqual({ + success: true, + output: { 'prefix-{{SECRET}}': 'safe' }, + }) + + expect( + projectFunctionResultForCopilot( + FunctionExecute.id, + { + success: true, + output: { 'secret-value': 'first', '{{SECRET}}': 'second' }, + }, + registry + ) + ).toEqual({ + success: true, + }) + }) + + it('omits content when one replacement creates another active literal', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'MIDDLE', plaintext: 'B', encryptedValue: 'encrypted-b' }, + { name: 'BRACE', plaintext: '{', encryptedValue: 'encrypted-brace' }, + { name: 'JOINED', plaintext: 'ac', encryptedValue: 'encrypted-ac' }, + ]) + registry.recordResolved('MIDDLE', 'B') + registry.recordResolved('BRACE', '{') + registry.recordResolved('JOINED', 'ac') + + expect( + projectFunctionResultForCopilot( + FunctionExecute.id, + { success: true, output: 'aBc' }, + registry + ) + ).toEqual({ success: true }) + }) + + it('keeps the control error safe from active one-character values', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'F_SECRET', plaintext: 'F', encryptedValue: 'encrypted-f' }, + ]) + registry.recordResolved('F_SECRET', 'F') + + const projected = projectFunctionResultForCopilot( + FunctionExecute.id, + { + success: false, + output: { F: 'first', '': 'second' }, + error: 'F', + }, + registry + ) + + expect(projected.success).toBe(false) + expect(projected).not.toHaveProperty('output') + expect(projected.error).toBeTruthy() + expect(projected.error).not.toContain('F') + }) + + it('does not project transformed values', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const encoded = Buffer.from('secret-value').toString('base64') + + expect( + projectFunctionResultForCopilot( + FunctionExecute.id, + { success: true, output: { result: encoded } }, + registry + ) + ).toEqual({ success: true, output: { result: encoded } }) + }) + + it('leaves configured but unused values unchanged', () => { + const registry = createRegistry() + const result = { + success: true, + output: { result: 'secret-value', stdout: '' }, + } + + expect(projectFunctionResultForCopilot(FunctionExecute.id, result, registry)).toEqual(result) + }) + + it.each([ + ['missing', undefined], + [ + 'incomplete', + (() => { + const registry = createRegistry() + registry.markIncomplete() + return registry + })(), + ], + ])('fails closed for %s provenance without changing structural fields', (_label, registry) => { + expect( + projectFunctionResultForCopilot( + FunctionExecute.id, + { + success: false, + output: { result: 'possibly-secret' }, + error: 'possibly-secret-error', + resources: [{ type: 'file', id: 'file-1', title: 'report.txt' }], + }, + registry + ) + ).toEqual({ + success: false, + error: FUNCTION_RESULT_OMITTED_ERROR, + resources: [{ type: 'file', id: 'file-1', title: 'report.txt' }], + }) + }) + + it('does not project unrelated tool results', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const result = { success: true, output: 'secret-value' } + + expect(projectFunctionResultForCopilot(Read.id, result, registry)).toBe(result) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts new file mode 100644 index 00000000000..ffff7dc57c9 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts @@ -0,0 +1,90 @@ +import { omit } from '@sim/utils/object' +import { FunctionExecute, RunCode } from '@/lib/copilot/generated/tool-catalog-v1' +import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { + containsResolvedSecret, + createResolvedSecretMatcher, + projectResolvedSecretContent, + type ResolvedSecretMatcher, + sanitizeResolvedSecretString, +} from '@/executor/utils/resolved-secret-content-projection' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export const FUNCTION_RESULT_OMITTED_ERROR = 'Function result omitted' + +function isFunctionSandboxTool(toolName: string): boolean { + return toolName === FunctionExecute.id || toolName === RunCode.id +} + +function omitContent(result: ToolExecutionResult): ToolExecutionResult { + return omit(result, ['output', 'error']) +} + +/** Returns a nonempty control error that cannot contain any active literal. */ +function createSafeControlError(matcher: ResolvedSecretMatcher | undefined): string { + if (!matcher) return FUNCTION_RESULT_OMITTED_ERROR + + try { + const projected = sanitizeResolvedSecretString(FUNCTION_RESULT_OMITTED_ERROR, matcher) + if (projected.length > 0 && !containsResolvedSecret(projected, matcher)) return projected + } catch {} + + for (let codePoint = 0x21; codePoint <= 0x10ffff; codePoint += 1) { + if (codePoint >= 0xd800 && codePoint <= 0xdfff) { + codePoint = 0xdfff + continue + } + const candidate = String.fromCodePoint(codePoint) + if (!containsResolvedSecret(candidate, matcher)) return candidate + } + + throw new Error('Active secret matcher covers every Unicode scalar') +} + +function omittedResult( + result: ToolExecutionResult, + matcher: ResolvedSecretMatcher | undefined +): ToolExecutionResult { + const structural = omitContent(result) + return result.success ? structural : { ...structural, error: createSafeControlError(matcher) } +} + +/** + * Projects only the Function sandbox content that can cross into Copilot. + * Runtime output remains local and unchanged for post-processing and resource side effects. + */ +export function projectFunctionResultForCopilot( + toolName: string, + result: ToolExecutionResult, + registry: ResolvedSecretTraceRegistry | undefined +): ToolExecutionResult { + if (!isFunctionSandboxTool(toolName)) return result + if (!registry?.isComplete()) return omittedResult(result, undefined) + + let matcher: ResolvedSecretMatcher | undefined + try { + matcher = createResolvedSecretMatcher(registry.getActiveMatches()) + if (!matcher) return result + + const content: Record = {} + if (Object.hasOwn(result, 'output')) content.output = result.output + if (Object.hasOwn(result, 'error')) content.error = result.error + const projection = projectResolvedSecretContent(content, matcher) + if (!projection.safe || !projection.value || typeof projection.value !== 'object') { + return omittedResult(result, matcher) + } + + const projectedContent = projection.value as Record + const projected = omitContent(result) + if (Object.hasOwn(projectedContent, 'output')) projected.output = projectedContent.output + if (Object.hasOwn(projectedContent, 'error')) { + projected.error = String(projectedContent.error) + } + if (!projected.success && !projected.error) { + projected.error = createSafeControlError(matcher) + } + return projected + } catch { + return omittedResult(result, matcher) + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/context.ts b/apps/sim/lib/copilot/tools/handlers/context.ts index 02ba4d078be..06f1c05716c 100644 --- a/apps/sim/lib/copilot/tools/handlers/context.ts +++ b/apps/sim/lib/copilot/tools/handlers/context.ts @@ -3,8 +3,11 @@ import { type BillingAttributionSnapshot, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + type CopilotEnvironmentContext, + prepareCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' import type { ExecutionContext } from '@/lib/copilot/request/types' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { getWorkflowById } from '@/lib/workflows/utils' export async function prepareExecutionContext( @@ -13,14 +16,14 @@ export async function prepareExecutionContext( chatId?: string, options?: { workspaceId?: string - decryptedEnvVars?: Record + environmentContext?: CopilotEnvironmentContext billingAttribution?: BillingAttributionSnapshot } ): Promise { const workspaceId = options?.workspaceId ?? (await getWorkflowById(workflowId))?.workspaceId ?? undefined - const [decryptedEnvVars, billingAttribution] = await Promise.all([ - options?.decryptedEnvVars ?? getEffectiveDecryptedEnv(userId, workspaceId), + const [environmentContext, billingAttribution] = await Promise.all([ + options?.environmentContext ?? prepareCopilotEnvironmentContext(userId, workspaceId), options?.billingAttribution ? Promise.resolve(assertBillingAttributionSnapshot(options.billingAttribution)) : workspaceId @@ -39,7 +42,7 @@ export async function prepareExecutionContext( workflowId, workspaceId, chatId, - decryptedEnvVars, + ...environmentContext, billingAttribution, } } diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.ts b/apps/sim/lib/logs/execution/trace-secret-projection.ts index 0632dc231c7..ac94e18ea55 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.ts @@ -19,18 +19,18 @@ import type { LargeValueStoreContext } from '@/lib/execution/payloads/store' import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store' import type { ToolCall, TraceSpan } from '@/lib/logs/types' import type { IterationToolCall, ProviderTimingSegment } from '@/executor/types' -import type { - ResolvedSecretTraceMatch, - ResolvedSecretTraceRegistry, -} from '@/executor/utils/resolved-secret-trace-registry' +import { + containsResolvedSecret, + createResolvedSecretMatcher, + type ResolvedSecretMatcher, + sanitizeResolvedSecretString, +} from '@/executor/utils/resolved-secret-content-projection' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('TraceSecretProjection') const REF_CONCURRENCY = 4 const MAX_CONTENT_NODES = 100_000 const MAX_CONTENT_DEPTH = 100 -const MAX_MATCHER_NODES = 250_000 -const MAX_SECRET_LITERAL_LENGTH = 64 * 1024 -const MAX_MATCH_EVENTS = 1_000_000 const MAX_LARGE_VALUES = 1_024 const MAX_LARGE_VALUE_CHAIN_DEPTH = 32 const MAX_LARGE_MANIFEST_CHUNKS = MAX_LARGE_VALUES @@ -65,25 +65,8 @@ const LARGE_ARRAY_MANIFEST_KEYS = new Set([ ]) const LARGE_ARRAY_MANIFEST_CHUNK_KEYS = new Set(['ref', 'count', 'byteSize']) -interface SecretReplacement { - plaintext: string - replacement: string -} - -interface SecretTrieNode { - children: Map - failure?: SecretTrieNode - outputLink?: SecretTrieNode - replacement?: SecretReplacement -} - -interface SecretMatcher { - root: SecretTrieNode - maxPatternLength: number -} - interface ProjectionContext { - matcher: SecretMatcher + matcher: ResolvedSecretMatcher store: LargeValueStoreContext allowLargeValueWrites: boolean safeLargeValues: WeakSet @@ -119,7 +102,7 @@ interface SanitizationTraversalState extends TraversalState { } interface PlaintextInvariantContext { - matcher: SecretMatcher + matcher: ResolvedSecretMatcher safeLargeValues: WeakSet /** Valid refs are trusted only after the full projector has already rewritten them. */ allowVerifiedLargeValues?: boolean @@ -150,95 +133,8 @@ class TraceSecretProjectionError extends Error { } } -function compareStrings(left: string, right: string): number { - if (left < right) return -1 - if (left > right) return 1 - return 0 -} - -function normalizeReplacements(matches: readonly ResolvedSecretTraceMatch[]): SecretReplacement[] { - const replacementByPlaintext = new Map() - - for (const match of matches) { - if (!match.plaintext) continue - const current = replacementByPlaintext.get(match.plaintext) - if (current === undefined || compareStrings(match.replacement, current) < 0) { - replacementByPlaintext.set(match.plaintext, match.replacement) - } - } - - const provisional = [...replacementByPlaintext.keys()] - .map((plaintext) => { - const requested = replacementByPlaintext.get(plaintext) ?? '' - return { plaintext, replacement: requested } - }) - .sort( - (left, right) => - right.plaintext.length - left.plaintext.length || - compareStrings(left.replacement, right.replacement) || - compareStrings(left.plaintext, right.plaintext) - ) - - const detector = createSecretMatcher( - provisional.map(({ plaintext }) => ({ plaintext, replacement: '' })) - ) - return provisional.map(({ plaintext, replacement }) => ({ - plaintext, - replacement: containsSecret(replacement, detector) ? '' : replacement, - })) -} - -function createSecretMatcher(replacements: readonly SecretReplacement[]): SecretMatcher { - const root: SecretTrieNode = { children: new Map() } - root.failure = root - let nodeCount = 1 - let maxPatternLength = 0 - for (const replacement of replacements) { - if (replacement.plaintext.length > MAX_SECRET_LITERAL_LENGTH) { - throw new TraceSecretProjectionError('Secret literal exceeds the matcher size limit') - } - maxPatternLength = Math.max(maxPatternLength, replacement.plaintext.length) - let node = root - for (let index = 0; index < replacement.plaintext.length; index += 1) { - const character = replacement.plaintext[index] - let child = node.children.get(character) - if (!child) { - child = { children: new Map() } - node.children.set(character, child) - nodeCount += 1 - if (nodeCount > MAX_MATCHER_NODES) { - throw new TraceSecretProjectionError('Secret matcher node limit exceeded') - } - } - node = child - } - node.replacement = replacement - } - - const queue: SecretTrieNode[] = [] - for (const child of root.children.values()) { - child.failure = root - queue.push(child) - } - for (let cursor = 0; cursor < queue.length; cursor += 1) { - const node = queue[cursor] - for (const [character, child] of node.children) { - let fallback = node.failure ?? root - while (fallback !== root && !fallback.children.has(character)) { - fallback = fallback.failure ?? root - } - const transition = fallback.children.get(character) - child.failure = transition && transition !== child ? transition : root - child.outputLink = child.failure.replacement ? child.failure : child.failure.outputLink - queue.push(child) - } - } - - return { root, maxPatternLength } -} - function createProjectionContext( - matcher: SecretMatcher, + matcher: ResolvedSecretMatcher, store: LargeValueStoreContext, allowLargeValueWrites: boolean ): ProjectionContext { @@ -260,108 +156,6 @@ function createProjectionContext( } } -function advanceMatcher( - matcher: SecretMatcher, - node: SecretTrieNode, - character: string -): SecretTrieNode { - let current = node - while (current !== matcher.root && !current.children.has(character)) { - current = current.failure ?? matcher.root - } - return current.children.get(character) ?? matcher.root -} - -function containsSecret(value: string, matcher: SecretMatcher): boolean { - let node = matcher.root - for (let index = 0; index < value.length; index += 1) { - node = advanceMatcher(matcher, node, value[index]) - if (node.replacement || node.outputLink) return true - } - return false -} - -function sanitizeString( - value: string, - matcher: SecretMatcher, - maxBytes = MAX_INLINE_MATERIALIZATION_BYTES -): string { - if (maxBytes < 0) { - throw new TraceSecretProjectionError('Sanitized trace string exceeds the size limit') - } - if (Buffer.byteLength(value, 'utf8') > maxBytes) { - throw new TraceSecretProjectionError('Trace string exceeds the size limit') - } - if (matcher.maxPatternLength === 0 || value.length === 0) return value - - let emitCursor = 0 - let literalStart = 0 - let outputBytes = 0 - let matchEvents = 0 - const chunks: string[] = [] - const windowSize = matcher.maxPatternLength - const slotStarts = new Int32Array(windowSize) - const slotEnds = new Int32Array(windowSize) - slotStarts.fill(-1) - const slotReplacements = new Array(windowSize) - - const append = (chunk: string): void => { - if (!chunk) return - outputBytes += Buffer.byteLength(chunk, 'utf8') - if (outputBytes > maxBytes) { - throw new TraceSecretProjectionError('Sanitized trace string exceeds the size limit') - } - const lastIndex = chunks.length - 1 - if (lastIndex >= 0 && chunks[lastIndex].length + chunk.length <= 64 * 1024) { - chunks[lastIndex] += chunk - } else { - chunks.push(chunk) - } - } - - const finalizeThrough = (limit: number): void => { - while (emitCursor <= limit && emitCursor < value.length) { - const slot = emitCursor % windowSize - if (slotStarts[slot] === emitCursor && slotReplacements[slot] !== undefined) { - append(value.slice(literalStart, emitCursor)) - append(slotReplacements[slot] ?? '') - emitCursor = slotEnds[slot] - literalStart = emitCursor - } else { - emitCursor += 1 - } - } - } - - let node = matcher.root - for (let index = 0; index < value.length; index += 1) { - node = advanceMatcher(matcher, node, value[index]) - let outputNode: SecretTrieNode | undefined = node.replacement ? node : node.outputLink - while (outputNode?.replacement) { - matchEvents += 1 - if (matchEvents > MAX_MATCH_EVENTS) { - throw new TraceSecretProjectionError('Secret matcher event limit exceeded') - } - const start = index - outputNode.replacement.plaintext.length + 1 - if (start >= emitCursor) { - const slot = start % windowSize - const end = index + 1 - if (slotStarts[slot] !== start || end > slotEnds[slot]) { - slotStarts[slot] = start - slotEnds[slot] = end - slotReplacements[slot] = outputNode.replacement.replacement - } - } - outputNode = outputNode.outputLink - } - finalizeThrough(index - matcher.maxPatternLength + 1) - } - - finalizeThrough(value.length - 1) - append(value.slice(literalStart)) - return chunks.join('') -} - function visitNode(state: TraversalState, depth: number): void { state.nodes += 1 if (state.nodes > MAX_CONTENT_NODES) { @@ -593,21 +387,29 @@ function getLargeValueCandidate(value: unknown): LargeValueCandidate | undefined function sanitizeInlineValue( value: unknown, - matcher: SecretMatcher, + matcher: ResolvedSecretMatcher, safeLargeValues: WeakSet, state: SanitizationTraversalState, depth = 0 ): unknown { visitNode(state, depth) if (typeof value === 'string') { - const sanitized = sanitizeString(value, matcher, state.maxBytes - state.outputBytes) + const sanitized = sanitizeResolvedSecretString( + value, + matcher, + state.maxBytes - state.outputBytes + ) state.outputBytes += Buffer.byteLength(sanitized, 'utf8') return sanitized } if (value === null || typeof value === 'number' || typeof value === 'boolean') { const rendered = String(value) - if (!containsSecret(rendered, matcher)) return value - const sanitized = sanitizeString(rendered, matcher, state.maxBytes - state.outputBytes) + if (!containsResolvedSecret(rendered, matcher)) return value + const sanitized = sanitizeResolvedSecretString( + rendered, + matcher, + state.maxBytes - state.outputBytes + ) state.outputBytes += Buffer.byteLength(sanitized, 'utf8') return sanitized } @@ -641,7 +443,11 @@ function sanitizeInlineValue( const sanitized = Object.create(prototype) as Record const sanitizedKeys = new Set() for (const [key, item] of enumerableDataEntries(value)) { - const sanitizedKey = sanitizeString(key, matcher, state.maxBytes - state.outputBytes) + const sanitizedKey = sanitizeResolvedSecretString( + key, + matcher, + state.maxBytes - state.outputBytes + ) state.outputBytes += Buffer.byteLength(sanitizedKey, 'utf8') if (sanitizedKeys.has(sanitizedKey)) { throw new TraceSecretProjectionError('Secret replacement caused an object-key collision') @@ -1473,13 +1279,13 @@ function assertNoPlaintext( ): void { visitNode(state, depth) if (typeof value === 'string') { - if (containsSecret(value, context.matcher)) { + if (containsResolvedSecret(value, context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace content still contains a secret') } return } if (value === null || typeof value === 'number' || typeof value === 'boolean') { - if (containsSecret(String(value), context.matcher)) { + if (containsResolvedSecret(String(value), context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace primitive still contains a secret') } return @@ -1519,7 +1325,7 @@ function assertNoPlaintext( return } for (const [key, item] of enumerableDataEntries(value)) { - if (containsSecret(key, context.matcher)) { + if (containsResolvedSecret(key, context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace key still contains a secret') } assertNoPlaintext(item, context, state, depth + 1) @@ -1746,7 +1552,7 @@ async function verifyPostTransformLargeValues( async function assertPostTransformTraceSpansAreSafe( traceSpans: TraceSpan[], - matcher: SecretMatcher, + matcher: ResolvedSecretMatcher, store: LargeValueStoreContext ): Promise { const projection = createProjectionContext(matcher, store, false) @@ -1788,14 +1594,10 @@ export async function enforceTraceSpanSecretInvariant( try { if (!options.registry?.isComplete()) return structuralOnlyTraceSpans(traceSpans) - const replacements = normalizeReplacements(options.registry.getActiveMatches()) - if (replacements.length === 0) return traceSpans + const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches()) + if (!matcher) return traceSpans - await assertPostTransformTraceSpansAreSafe( - traceSpans, - createSecretMatcher(replacements), - options.store - ) + await assertPostTransformTraceSpansAreSafe(traceSpans, matcher, options.store) return traceSpans } catch { logger.warn('Trace secret invariant failed; retaining structural spans only') @@ -1816,11 +1618,11 @@ export async function projectTraceSpansForSecrets( } try { - const replacements = normalizeReplacements(options.registry.getActiveMatches()) - if (replacements.length === 0) return cloneTraceSpansForProjection(traceSpans) + const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches()) + if (!matcher) return cloneTraceSpansForProjection(traceSpans) const context = createProjectionContext( - createSecretMatcher(replacements), + matcher, options.store, options.allowLargeValueWrites !== false ) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index d3895b9c367..ccec1bfc8b1 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -700,7 +700,10 @@ describe('executeTool Function', () => { expect(new Headers(requestInit?.headers).get('x-sim-request-private-tool-metadata')).toBe( 'resolved-secret-names-v1' ) - expect(result.output).not.toHaveProperty('__resolvedSecretNames') + expect(result.output).toEqual({ + success: true, + output: { result: 'secret-value', stdout: '' }, + }) expect(registry.getActiveMatches()).toEqual([ { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, ]) From 0ba34a698170db891b7c65f9e2abea65f646635b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 1 Aug 2026 23:06:32 -0700 Subject: [PATCH 2/8] improvement(chat): secrets mounting / exposure improvements and controls --- .../content/docs/en/platform/credentials.mdx | 16 +- .../sim/app/api/copilot/confirm/route.test.ts | 345 +- apps/sim/app/api/copilot/confirm/route.ts | 136 +- .../api/copilot/tool-permission/route.test.ts | 151 + .../app/api/copilot/tool-permission/route.ts | 35 +- apps/sim/app/api/credentials/route.test.ts | 60 +- apps/sim/app/api/credentials/route.ts | 5 +- .../app/api/function/execute/route.test.ts | 28 + apps/sim/app/api/function/execute/route.ts | 3 +- .../app/api/mothership/execute/route.test.ts | 36 +- apps/sim/app/api/mothership/execute/route.ts | 7 + apps/sim/app/api/schedules/[id]/route.ts | 11 +- apps/sim/app/api/schedules/route.ts | 4 + .../[id]/execute/route.async.test.ts | 189 +- .../app/api/workflows/[id]/execute/route.ts | 82 +- .../workspaces/[id]/environment/route.test.ts | 48 +- .../api/workspaces/[id]/environment/route.ts | 41 +- .../api/workspaces/[id]/inbox/route.test.ts | 87 + .../app/api/workspaces/[id]/inbox/route.ts | 70 +- .../agent-group/tool-permission-card.tsx | 47 +- .../task-context-menu/task-context-menu.tsx | 12 +- .../task-details-modal/task-details-modal.tsx | 4 +- .../task-modal/secret-access-section.tsx | 67 + .../components/task-modal/task-modal.tsx | 15 +- .../hooks/use-scheduled-tasks.ts | 6 + .../scheduled-tasks/scheduled-tasks.tsx | 12 +- .../utils/schedule-events.test.ts | 10 +- .../scheduled-tasks/utils/schedule-events.ts | 3 + .../inbox-settings-tab/inbox-settings-tab.tsx | 71 + .../utils/workflow-execution-utils.ts | 2 + apps/sim/background/schedule-execution.ts | 2 + apps/sim/blocks/blocks/mothership.ts | 28 + apps/sim/blocks/types.ts | 2 + .../executor/execution/block-executor.test.ts | 66 + apps/sim/executor/execution/block-executor.ts | 19 +- .../mothership/mothership-handler.test.ts | 4 + .../handlers/mothership/mothership-handler.ts | 7 + .../executor/utils/code-secret-references.ts | 38 + .../resolved-secret-content-projection.ts | 36 +- .../resolved-secret-trace-registry.test.ts | 28 + .../utils/resolved-secret-trace-registry.ts | 27 +- apps/sim/hooks/queries/credentials.ts | 6 +- apps/sim/hooks/queries/inbox.ts | 32 + .../sim/hooks/queries/secret-mount-options.ts | 19 + .../utils/fetch-workspace-credentials.ts | 2 + apps/sim/lib/api/contracts/copilot.ts | 1 + apps/sim/lib/api/contracts/inbox.ts | 10 + apps/sim/lib/api/contracts/index.ts | 1 + .../sim/lib/api/contracts/mothership-chats.ts | 6 + apps/sim/lib/api/contracts/schedules.ts | 10 + .../api/contracts/secret-mount-policy.test.ts | 32 + .../lib/api/contracts/secret-mount-policy.ts | 21 + apps/sim/lib/api/contracts/workflows.ts | 1 + .../lib/copilot/async-runs/repository.test.ts | 95 +- apps/sim/lib/copilot/async-runs/repository.ts | 120 +- apps/sim/lib/copilot/chat/post.test.ts | 2 - .../lib/copilot/environment-context.test.ts | 8 +- apps/sim/lib/copilot/environment-context.ts | 9 +- .../lib/copilot/generated/tool-catalog-v1.ts | 15 +- .../lib/copilot/generated/tool-schemas-v1.ts | 4 +- .../request/context/request-context.ts | 2 +- .../copilot/request/context/result.test.ts | 6 +- .../sim/lib/copilot/request/go/stream.test.ts | 6 +- apps/sim/lib/copilot/request/go/stream.ts | 2 +- .../copilot/request/handlers/handlers.test.ts | 223 +- apps/sim/lib/copilot/request/handlers/tool.ts | 117 +- .../lib/copilot/request/lifecycle/run.test.ts | 51 +- apps/sim/lib/copilot/request/lifecycle/run.ts | 50 +- .../tools/client-completion-seal.server.ts | 136 + .../lib/copilot/request/tools/client.test.ts | 567 + apps/sim/lib/copilot/request/tools/client.ts | 311 + .../sim/lib/copilot/request/tools/executor.ts | 12 +- apps/sim/lib/copilot/request/tools/files.ts | 9 +- .../copilot/request/tools/permission.test.ts | 51 +- .../lib/copilot/request/tools/permission.ts | 40 +- .../tools/resolved-secret-result.test.ts | 79 +- .../request/tools/resolved-secret-result.ts | 77 +- .../lib/copilot/request/tools/resources.ts | 35 +- .../lib/copilot/request/tools/tables.test.ts | 55 +- apps/sim/lib/copilot/request/tools/tables.ts | 23 +- apps/sim/lib/copilot/request/types.ts | 1 + .../lib/copilot/secret-mount-policy.test.ts | 54 + apps/sim/lib/copilot/secret-mount-policy.ts | 75 + .../copilot/tool-executor/executor.test.ts | 84 +- .../sim/lib/copilot/tool-executor/executor.ts | 20 +- apps/sim/lib/copilot/tool-executor/types.ts | 5 +- .../lib/copilot/tools/client/completion.ts | 5 +- .../tools/client/run-tool-execution.test.ts | 70 +- .../tools/client/run-tool-execution.ts | 135 +- .../tools/handlers/function-execute.test.ts | 259 +- .../tools/handlers/function-execute.ts | 157 +- .../tools/handlers/workflow/mutations.test.ts | 56 + .../tools/handlers/workflow/mutations.ts | 8 +- .../secret-mount-materializer.server.test.ts | 409 + .../tools/secret-mount-materializer.server.ts | 311 + .../lib/copilot/tools/secret-mount.test.ts | 50 + apps/sim/lib/copilot/tools/secret-mount.ts | 20 + .../blocks/get-blocks-metadata-tool.test.ts | 16 + .../server/blocks/get-blocks-metadata-tool.ts | 36 +- .../workflow/edit-workflow/validation.test.ts | 23 + .../workflow/edit-workflow/validation.ts | 11 + apps/sim/lib/copilot/tools/workflow-tools.ts | 60 + apps/sim/lib/copilot/vfs/serializers.test.ts | 38 + apps/sim/lib/copilot/vfs/serializers.ts | 6 +- apps/sim/lib/core/async-jobs/types.ts | 2 + apps/sim/lib/credentials/environment.test.ts | 83 + apps/sim/lib/credentials/environment.ts | 61 + .../credentials/secret-mount-options.test.ts | 36 + .../lib/credentials/secret-mount-options.ts | 22 + apps/sim/lib/environment/utils.test.ts | 81 +- apps/sim/lib/environment/utils.ts | 21 +- .../logs/execution/logging-session.test.ts | 41 +- .../sim/lib/logs/execution/logging-session.ts | 22 +- .../logs/execution/trace-secret-projection.ts | 99 +- .../sim/lib/mothership/inbox/executor.test.ts | 181 + apps/sim/lib/mothership/inbox/executor.ts | 32 +- .../executor/execution-state.test.ts | 140 + .../lib/workflows/executor/execution-state.ts | 122 +- .../sanitization/json-sanitizer.test.ts | 39 +- .../workflows/sanitization/json-sanitizer.ts | 12 +- .../workflows/schedules/orchestration.test.ts | 49 + .../lib/workflows/schedules/orchestration.ts | 42 +- apps/sim/lib/workflows/subblocks/options.ts | 21 + apps/sim/serializer/index.ts | 10 + apps/sim/serializer/private-inputs.test.ts | 80 + apps/sim/serializer/types.ts | 2 + apps/sim/tools/index.test.ts | 151 + apps/sim/tools/index.ts | 7 + packages/db/migrations/0280_great_riptide.sql | 5 + .../db/migrations/meta/0280_snapshot.json | 18398 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 4 + .../testing/src/mocks/logging-session.mock.ts | 11 +- 133 files changed, 25097 insertions(+), 699 deletions(-) create mode 100644 apps/sim/app/api/copilot/tool-permission/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/inbox/route.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx create mode 100644 apps/sim/executor/utils/code-secret-references.ts create mode 100644 apps/sim/hooks/queries/secret-mount-options.ts create mode 100644 apps/sim/lib/api/contracts/secret-mount-policy.test.ts create mode 100644 apps/sim/lib/api/contracts/secret-mount-policy.ts create mode 100644 apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts create mode 100644 apps/sim/lib/copilot/request/tools/client.test.ts create mode 100644 apps/sim/lib/copilot/secret-mount-policy.test.ts create mode 100644 apps/sim/lib/copilot/secret-mount-policy.ts create mode 100644 apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts create mode 100644 apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts create mode 100644 apps/sim/lib/copilot/tools/secret-mount.test.ts create mode 100644 apps/sim/lib/copilot/tools/secret-mount.ts create mode 100644 apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts create mode 100644 apps/sim/lib/credentials/secret-mount-options.test.ts create mode 100644 apps/sim/lib/credentials/secret-mount-options.ts create mode 100644 apps/sim/lib/mothership/inbox/executor.test.ts create mode 100644 apps/sim/serializer/private-inputs.test.ts create mode 100644 packages/db/migrations/0280_great_riptide.sql create mode 100644 packages/db/migrations/meta/0280_snapshot.json diff --git a/apps/docs/content/docs/en/platform/credentials.mdx b/apps/docs/content/docs/en/platform/credentials.mdx index e3f8bf94049..d554efb5305 100644 --- a/apps/docs/content/docs/en/platform/credentials.mdx +++ b/apps/docs/content/docs/en/platform/credentials.mdx @@ -77,7 +77,17 @@ Masking is activated only when Sim successfully resolves a value from **Settings ### Copilot code execution -When Copilot runs its built-in Function or code-execution tool, the sandbox still receives the real value for a successful `{{KEY}}` substitution. Before the tool result is returned to Copilot, exact occurrences of that activated value are replaced with `{{KEY}}`. This keeps the plaintext out of Copilot's tool-result context without changing the code that ran or its local runtime result. If Sim cannot verify the execution's secret provenance, it omits the result content instead of returning it unverified. Hardcoded, directly read, encoded, hashed, and otherwise transformed values follow the same limitations described above. +Copilot's Function and code-execution tools receive a saved secret only when their code explicitly contains a valid `{{KEY}}` reference. Direct `environmentVariables.KEY` access, shell `$KEY`, dynamic names, literals, and configured-but-unused secrets do not mount a value. Code execution requires workspace write access, and the caller must also be allowed to view the raw value: your own Personal secrets, any secret for which you are a Credential Admin, and Workspace secrets when you are a workspace admin. Credential Members can continue using shared secrets through normal workflow and tool resolution, but cannot mount their plaintext into arbitrary Copilot code. + +Interactive code calls require **Allow** for that individual call, even if the tool was previously allowed for the chat or account. Headless surfaces use their saved **Secret access** setting: + +- **Sim Chat block** — under **Show additional fields** +- **Scheduled Tasks** — in the task modal +- **Inbox** — under **Settings → Inbox → Secrets** + +Choose **All secrets** or **Selected secrets**. Existing configurations default to **All secrets** for compatibility. **All secrets** still means only secrets explicitly referenced with `{{KEY}}` that the execution actor may view; it never injects the full environment. Inbox messages from allowed external senders do not receive raw-secret access. + +Code receives the real authorized value at runtime. Before any Copilot-visible tool result is returned, exact occurrences of activated secret values are replaced with `{{KEY}}`; local side effects and runtime results are not rewritten. Encoded, hashed, URL-encoded, otherwise transformed, or network-exfiltrated values cannot be inferred and masked reliably, so code should not deliberately return, transform, print, or transmit secrets to unintended destinations. ## Secret Details @@ -126,8 +136,8 @@ When a workflow runs, secrets resolve in this order: ({ getAsyncToolCall: vi.fn(), getRunSegment: vi.fn(), - upsertAsyncToolCall: vi.fn(), completeAsyncToolCall: vi.fn(), + detachAsyncToolCall: vi.fn(), publishToolConfirmation: vi.fn(), + encryptSecret: vi.fn(), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) @@ -24,14 +26,18 @@ vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) vi.mock('@/lib/copilot/async-runs/repository', () => ({ getAsyncToolCall, getRunSegment, - upsertAsyncToolCall, completeAsyncToolCall, + detachAsyncToolCall, })) vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ publishToolConfirmation, })) +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret, +})) + import { POST } from './route' describe('Copilot Confirm API Route', () => { @@ -41,6 +47,7 @@ describe('Copilot Confirm API Route', () => { checkpointId: 'checkpoint-1', toolName: 'client_tool', args: { foo: 'bar' }, + status: 'running', } beforeEach(() => { @@ -50,9 +57,14 @@ describe('Copilot Confirm API Route', () => { isAuthenticated: true, }) getAsyncToolCall.mockResolvedValue(existingRow) - getRunSegment.mockResolvedValue({ id: 'run-1', userId: 'user-1' }) - upsertAsyncToolCall.mockResolvedValue(existingRow) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + workflowId: 'workflow-from-run', + }) completeAsyncToolCall.mockResolvedValue(existingRow) + detachAsyncToolCall.mockResolvedValue(existingRow) + encryptSecret.mockResolvedValue({ encrypted: 'sealed-client-result', iv: 'iv' }) }) function createMockPostRequest(body: Record): NextRequest { @@ -122,15 +134,15 @@ describe('Copilot Confirm API Route', () => { expect(completeAsyncToolCall).toHaveBeenCalledWith({ toolCallId: 'tool-call-123', status: 'completed', - result: { ok: true }, + result: { __sealedClientToolCompletionV1: 'sealed-client-result' }, error: null, }) - expect(upsertAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() expect(publishToolConfirmation).toHaveBeenCalledWith( expect.objectContaining({ toolCallId: 'tool-call-123', status: 'success', - data: { ok: true }, + data: { __sealedClientToolCompletionV1: 'sealed-client-result' }, }) ) }) @@ -149,19 +161,58 @@ describe('Copilot Confirm API Route', () => { expect(completeAsyncToolCall).toHaveBeenCalledWith({ toolCallId: 'tool-call-123', status: 'completed', - result: 'done', + result: { __sealedClientToolCompletionV1: 'sealed-client-result' }, error: null, }) expect(publishToolConfirmation).toHaveBeenCalledWith( expect.objectContaining({ toolCallId: 'tool-call-123', status: 'success', - data: 'done', + data: { __sealedClientToolCompletionV1: 'sealed-client-result' }, + }) + ) + }) + + it('keeps generic client content sealed in durable and pubsub payloads', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + result: { __sealedClientToolContextV1: 'sealed-context' }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'error', + message: 'failed near resolved-secret', + data: { output: 'resolved-secret' }, + }) + ) + + expect(response.status).toBe(200) + const sealedResult = { + __sealedClientToolContextV1: 'sealed-context', + __sealedClientToolCompletionV1: 'sealed-client-result', + } + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: sealedResult, + error: 'Tool failed', + }) + expect(publishToolConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + toolCallId: 'tool-call-123', + status: 'error', + message: 'Tool failed', + data: sealedResult, }) ) + expect(await response.json()).toMatchObject({ message: 'Tool failed' }) + expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('resolved-secret') + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('resolved-secret') }) - it('keeps background as a live pending detach confirmation', async () => { + it('atomically detaches a live background confirmation', async () => { const response = await POST( createMockPostRequest({ toolCallId: 'tool-call-123', @@ -170,8 +221,8 @@ describe('Copilot Confirm API Route', () => { ) expect(response.status).toBe(200) - expect(upsertAsyncToolCall).not.toHaveBeenCalled() expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123') expect(publishToolConfirmation).toHaveBeenCalledWith( expect.objectContaining({ toolCallId: 'tool-call-123', @@ -180,6 +231,276 @@ describe('Copilot Confirm API Route', () => { ) }) + it('rejects a native confirmation before the desktop authorization claim', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'browser_snapshot', + status: 'pending', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'success', + data: { text: 'forged renderer result' }, + }) + ) + + expect(response.status).toBe(404) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(encryptSecret).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('does not publish when another terminal confirmation already won', async () => { + completeAsyncToolCall.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'success', + }) + ) + + expect(response.status).toBe(500) + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('does not publish a background replay after the call was finalized', async () => { + detachAsyncToolCall.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'background', + }) + ) + + expect(response.status).toBe(500) + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('treats a workflow success as a notification and persists only canonical structure', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + message: 'Completed with resolved-secret', + data: { + success: true, + output: { token: 'prefix-resolved-secret-suffix' }, + logs: ['resolved-secret'], + }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: null, + }) + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + message: 'Workflow execution completed.', + timestamp: expect.any(String), + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(await response.json()).toEqual({ + success: true, + message: 'Workflow execution completed.', + toolCallId: 'tool-call-123', + status: 'success', + }) + expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('resolved-secret') + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('resolved-secret') + }) + + it('persists workflow failure structure without accepting client errors', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_block', + args: { workflowId: 'workflow-1' }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'error', + message: 'Function failed with resolved-secret', + data: { success: false, error: 'resolved-secret is invalid' }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: 'Workflow execution failed.', + }) + const published = publishToolConfirmation.mock.calls[0][0] + expect(published.data).toEqual(completeAsyncToolCall.mock.calls[0][0].result) + expect(published.message).toBe(completeAsyncToolCall.mock.calls[0][0].error) + expect(JSON.stringify(published)).not.toContain('resolved-secret') + }) + + it('uses structural workflow data without accepting execution content', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'error', + message: 'Failed with unresolved-secret-value', + data: { + success: false, + output: 'unresolved-secret-value', + reason: 'provider_failure', + }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls[0][0])).not.toContain( + 'unresolved-secret-value' + ) + }) + + it('binds output identity to the stored workflow target', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_from_block', + args: { workflowId: 'stored-workflow' }, + }) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + workflowId: 'run-workflow', + }) + + await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'submitted-execution', + status: 'success', + data: { workflowId: 'submitted-workflow', output: 'raw-output' }, + }) + ) + + expect(completeAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + result: { + success: true, + workflowId: 'stored-workflow', + executionId: 'submitted-execution', + }, + }) + ) + }) + + it('keeps workflow completion structural even for ordinary client content', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: {}, + }) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + workflowId: 'workflow-from-run', + }) + + await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + message: 'Workflow returned a normal value', + data: { output: { value: 'normal-value' }, logs: [] }, + }) + ) + + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-from-run', + executionId: 'execution-1', + }, + error: null, + }) + expect(publishToolConfirmation.mock.calls[0][0].message).toBe('Workflow execution completed.') + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('normal-value') + }) + + it('keeps workflow background confirmations structural without loading provenance', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow_until_block', + args: { workflowId: 'workflow-1' }, + }) + + await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'background', + message: 'Raw background detail', + data: { lastEventId: 7 }, + }) + ) + + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'background', + message: 'Workflow execution is continuing in the background.', + timestamp: expect.any(String), + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + }) + it('rejects unsupported accepted and rejected confirmation statuses', async () => { const acceptedResponse = await POST( createMockPostRequest({ diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index 1dd5bd98a12..71e4a67795e 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -1,4 +1,6 @@ +import { isBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' +import { isTerminalToolName } from '@sim/terminal-protocol' import { getErrorMessage, toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { copilotConfirmContract } from '@/lib/api/contracts/copilot' @@ -11,9 +13,9 @@ import { } from '@/lib/copilot/async-runs/lifecycle' import { completeAsyncToolCall, + detachAsyncToolCall, getAsyncToolCall, getRunSegment, - upsertAsyncToolCall, } from '@/lib/copilot/async-runs/repository' import { CopilotConfirmOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -27,69 +29,70 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { + retainSealedClientToolContext, + sealClientToolCompletion, +} from '@/lib/copilot/request/tools/client-completion-seal.server' +import { + createStructuralWorkflowToolCompletionData, + getWorkflowToolCompletionMessage, + isWorkflowToolName, + resolveWorkflowToolTargetId, +} from '@/lib/copilot/tools/workflow-tools' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotConfirmAPI') -/** - * Persist terminal durable tool status, then publish a wakeup event. - * - * `background` remains a live detach signal in the current browser workflow - * runtime, so it should not rewrite the durable async row. - */ +function getClientToolCompletionMessage(status: AsyncConfirmationStatus): string { + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) return 'Tool completed' + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) return 'Tool is running in background' + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) return 'Tool cancelled' + return 'Tool failed' +} + +/** Atomically finalize or detach a client tool before publishing its wakeup event. */ async function updateToolCallStatus( existing: NonNullable>>, status: AsyncConfirmationStatus, message?: string, - data?: AsyncCompletionData + data?: AsyncCompletionData, + executionId?: string ): Promise { const toolCallId = existing.toolCallId - if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { - publishToolConfirmation({ - toolCallId, - status, - message: message || undefined, - timestamp: new Date().toISOString(), - data, - }) - return true - } - const durableStatus = - status === 'success' - ? ASYNC_TOOL_STATUS.completed - : status === 'cancelled' - ? ASYNC_TOOL_STATUS.cancelled - : status === 'error' - ? ASYNC_TOOL_STATUS.failed - : ASYNC_TOOL_STATUS.pending try { - if ( - durableStatus === ASYNC_TOOL_STATUS.completed || - durableStatus === ASYNC_TOOL_STATUS.failed || - durableStatus === ASYNC_TOOL_STATUS.cancelled - ) { - await completeAsyncToolCall({ - toolCallId, - status: durableStatus, - result: data ?? null, - error: status === 'success' ? null : message || status, - }) - } else if (existing.runId) { - await upsertAsyncToolCall({ - runId: existing.runId, - checkpointId: existing.checkpointId ?? null, + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + const detached = await detachAsyncToolCall(toolCallId) + if (!detached) return false + publishToolConfirmation({ toolCallId, - toolName: existing.toolName || 'client_tool', - args: (existing.args as Record | null) ?? {}, - status: durableStatus, + status, + message: message || undefined, + timestamp: new Date().toISOString(), + data, + ...(executionId ? { executionId } : {}), }) + return true } + const durableStatus = + status === ASYNC_TOOL_CONFIRMATION_STATUS.success + ? ASYNC_TOOL_STATUS.completed + : status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + ? ASYNC_TOOL_STATUS.cancelled + : ASYNC_TOOL_STATUS.failed + const completed = await completeAsyncToolCall({ + toolCallId, + status: durableStatus, + result: data ?? null, + error: status === 'success' ? null : message || status, + }) + if (!completed) return false publishToolConfirmation({ toolCallId, status, message: message || undefined, timestamp: new Date().toISOString(), data, + ...(executionId ? { executionId } : {}), }) return true } catch (error) { @@ -140,7 +143,7 @@ export const POST = withRouteHandler((req: NextRequest) => { } ) if (!parsed.success) return parsed.response - const { toolCallId, status, message, data } = parsed.data.body + const { toolCallId, executionId, status, message, data } = parsed.data.body span.setAttributes({ [TraceAttr.ToolCallId]: toolCallId, [TraceAttr.ToolConfirmationStatus]: status, @@ -178,7 +181,44 @@ export const POST = withRouteHandler((req: NextRequest) => { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const updated = await updateToolCallStatus(existing, status, message, data) + if ( + (isBrowserToolName(existing.toolName) || isTerminalToolName(existing.toolName)) && + existing.status !== ASYNC_TOOL_STATUS.running + ) { + span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ToolCallNotFound) + return createNotFoundResponse('Running client tool call not found') + } + + const isWorkflowTool = isWorkflowToolName(existing.toolName || '') + const workflowId = isWorkflowTool + ? resolveWorkflowToolTargetId(existing.args, run.workflowId) + : undefined + const projected = isWorkflowTool + ? { + message: getWorkflowToolCompletionMessage(status), + data: createStructuralWorkflowToolCompletionData(status, workflowId, executionId), + } + : { + message: getClientToolCompletionMessage(status), + data: { + ...retainSealedClientToolContext(existing.result), + ...(await sealClientToolCompletion({ + toolCallId, + runId: existing.runId, + userId: authenticatedUserId, + ...(message !== undefined ? { message } : {}), + ...(data !== undefined ? { data } : {}), + })), + }, + } + + const updated = await updateToolCallStatus( + existing, + status, + projected.message, + projected.data, + isWorkflowTool ? executionId : undefined + ) if (!updated) { logger.error(`[${tracker.requestId}] Failed to update tool call status`, { @@ -186,7 +226,7 @@ export const POST = withRouteHandler((req: NextRequest) => { toolCallId, status, internalStatus: status, - message, + message: projected.message, }) span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.UpdateFailed) // DB write failed — 500, not 400. 400 is a client-shape error. @@ -196,7 +236,7 @@ export const POST = withRouteHandler((req: NextRequest) => { span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Delivered) return NextResponse.json({ success: true, - message: message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`, + message: projected.message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`, toolCallId, status, }) diff --git a/apps/sim/app/api/copilot/tool-permission/route.test.ts b/apps/sim/app/api/copilot/tool-permission/route.test.ts new file mode 100644 index 00000000000..e73439e6d62 --- /dev/null +++ b/apps/sim/app/api/copilot/tool-permission/route.test.ts @@ -0,0 +1,151 @@ +/** + * @vitest-environment node + */ + +import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockGetAsyncToolCall, + mockGetRunSegment, + mockRecordToolPermissionDecision, + mockPublishToolPermissionDecision, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetAsyncToolCall: vi.fn(), + mockGetRunSegment: vi.fn(), + mockRecordToolPermissionDecision: vi.fn(), + mockPublishToolPermissionDecision: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + getAsyncToolCall: mockGetAsyncToolCall, + getRunSegment: mockGetRunSegment, + recordToolPermissionDecision: mockRecordToolPermissionDecision, +})) + +vi.mock('@/lib/copilot/persistence/tool-permission', () => ({ + TOOL_PERMISSION_DECISION: { + allow: 'allow', + allow_chat: 'allow_chat', + always_allow: 'always_allow', + skip: 'skip', + }, + publishToolPermissionDecision: mockPublishToolPermissionDecision, +})) + +vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ + addAutoAllowedTool: vi.fn(), + addChatAutoAllowedTool: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/otel', () => ({ + withIncomingGoSpan: vi.fn( + async ( + _headers: unknown, + _spanName: unknown, + _attributes: unknown, + callback: (span: { setAttributes: (attributes: unknown) => void }) => Promise + ) => callback({ setAttributes: vi.fn() }) + ), +})) + +import { POST } from './route' + +afterAll(resetEnvFlagsMock) + +describe('Copilot tool permission decisions', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isCopilotToolPermissionsEnabled: false }) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetRunSegment.mockResolvedValue({ id: 'run-1', userId: 'user-1', chatId: 'chat-1' }) + mockRecordToolPermissionDecision.mockResolvedValue({ + toolCallId: 'call-1', + toolName: 'function_execute', + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + }) + + it('accepts one-call approval for a secret-bearing code call while the broad flag is off', async () => { + mockGetAsyncToolCall.mockResolvedValue({ + runId: 'run-1', + toolCallId: 'call-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{API_KEY}}' }, + permissionDecision: null, + }) + + const response = await POST( + createMockRequest( + 'POST', + { decisions: [{ toolCallId: 'call-1', decision: 'allow' }] }, + {}, + 'http://localhost:3000/api/copilot/tool-permission' + ) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + results: [{ toolCallId: 'call-1', decision: 'allow', applied: true }], + }) + expect(mockRecordToolPermissionDecision).toHaveBeenCalledWith('call-1', 'allow') + expect(mockPublishToolPermissionDecision).toHaveBeenCalledWith( + expect.objectContaining({ toolCallId: 'call-1', decision: 'allow' }) + ) + }) + + it('keeps ordinary tool permission decisions closed while the broad flag is off', async () => { + mockGetAsyncToolCall.mockResolvedValue({ + runId: 'run-1', + toolCallId: 'call-1', + toolName: 'terminal', + args: { operation: 'run', args: { command: 'ls' } }, + permissionDecision: null, + }) + + const response = await POST( + createMockRequest( + 'POST', + { decisions: [{ toolCallId: 'call-1', decision: 'allow' }] }, + {}, + 'http://localhost:3000/api/copilot/tool-permission' + ) + ) + + expect(response.status).toBe(404) + expect(mockRecordToolPermissionDecision).not.toHaveBeenCalled() + }) + + it.each(['allow_chat', 'always_allow'] as const)( + 'rejects persistent %s approval for a secret-bearing code call', + async (decision) => { + mockGetAsyncToolCall.mockResolvedValue({ + runId: 'run-1', + toolCallId: 'call-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{API_KEY}}' }, + permissionDecision: null, + }) + + const response = await POST( + createMockRequest( + 'POST', + { decisions: [{ toolCallId: 'call-1', decision }] }, + {}, + 'http://localhost:3000/api/copilot/tool-permission' + ) + ) + + expect(response.status).toBe(400) + expect(mockRecordToolPermissionDecision).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/app/api/copilot/tool-permission/route.ts b/apps/sim/app/api/copilot/tool-permission/route.ts index 536f0d1b3cc..bd70b6a0283 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { type NextRequest, NextResponse } from 'next/server' import { copilotToolPermissionContract } from '@/lib/api/contracts/copilot' import { parseRequest, validationErrorResponse } from '@/lib/api/server' @@ -21,12 +22,14 @@ import { } from '@/lib/copilot/persistence/tool-permission/auto-allow' import { authenticateCopilotRequestSessionOnly, + createBadRequestResponse, createInternalServerErrorResponse, createNotFoundResponse, createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { getToolSecretMountNames } from '@/lib/copilot/tools/secret-mount' import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -38,6 +41,10 @@ interface DecisionResult { applied: boolean } +interface RejectedDecision { + rejection: 'permission-feature-disabled' | 'persistent-secret-permission' +} + /** * Records one prompt answer and wakes the orchestrator waiting on it. * @@ -49,7 +56,7 @@ async function applyDecision( toolCallId: string, decision: ToolPermissionDecision, userId: string -): Promise { +): Promise { const existing = await getAsyncToolCall(toolCallId).catch((err) => { logger.warn('Failed to fetch async tool call', { toolCallId, error: getErrorMessage(err) }) return null @@ -65,6 +72,19 @@ async function applyDecision( }) if (!run || run.userId !== userId) return null + const args = isRecordLike(existing.args) ? existing.args : undefined + const mountsSecrets = getToolSecretMountNames(existing.toolName, args).length > 0 + if (!isCopilotToolPermissionsEnabled && !mountsSecrets) { + return { rejection: 'permission-feature-disabled' } + } + if ( + mountsSecrets && + (decision === TOOL_PERMISSION_DECISION.allow_chat || + decision === TOOL_PERMISSION_DECISION.always_allow) + ) { + return { rejection: 'persistent-secret-permission' } + } + const claimed = await recordToolPermissionDecision(toolCallId, decision) if (!claimed) { // Someone already answered. Report their decision rather than pretending @@ -117,13 +137,6 @@ export const POST = withRouteHandler((req: NextRequest) => { { [TraceAttr.RequestId]: tracker.requestId }, async (span) => { try { - // Nothing can legitimately be awaiting a decision while the feature is - // off, so close the endpoint rather than letting it write decisions - // onto rows no orchestrator is waiting on. - if (!isCopilotToolPermissionsEnabled) { - return createNotFoundResponse('Tool permissions are not enabled') - } - const { userId: authenticatedUserId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() @@ -154,6 +167,12 @@ export const POST = withRouteHandler((req: NextRequest) => { const results: DecisionResult[] = [] for (const { toolCallId, decision } of decisions) { const result = await applyDecision(toolCallId, decision, authenticatedUserId) + if (result && 'rejection' in result) { + if (result.rejection === 'permission-feature-disabled') { + return createNotFoundResponse('Tool permissions are not enabled') + } + return createBadRequestResponse('Secret-bearing code calls can only be allowed once') + } if (result) results.push(result) } diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index e9a4a57e8e4..6127ba4b162 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -3,12 +3,14 @@ * * @vitest-environment node */ +import { credential } from '@sim/db/schema' import { auditMock, authMockFns, createMockRequest, dbChainMockFns, posthogServerMock, + queueTableRows, resetDbChainMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -52,10 +54,66 @@ vi.mock('@/lib/credentials/service-account-secret', () => ({ ServiceAccountSecretError: class ServiceAccountSecretError extends Error {}, })) -import { POST } from '@/app/api/credentials/route' +import { GET, POST } from '@/app/api/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +describe('GET /api/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + }) + mockCheckWorkspaceAccess.mockResolvedValue({ + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + }) + + it('reports an owned personal secret as raw-view admin without a membership row', async () => { + queueTableRows(credential, [ + { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'env_personal', + displayName: 'MY_API_KEY', + description: null, + providerId: null, + accountId: null, + envKey: 'MY_API_KEY', + envOwnerUserId: 'user-1', + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + memberRole: null, + }, + ]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/credentials?workspaceId=${WORKSPACE_ID}` + ) + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.credentials).toEqual([ + expect.objectContaining({ + id: 'credential-1', + type: 'env_personal', + envKey: 'MY_API_KEY', + envOwnerUserId: 'user-1', + role: 'admin', + }), + ]) + }) +}) + describe('POST /api/credentials', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 4efe507fa9a..74b3b6337f1 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -270,7 +270,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const credentials = rows.map(({ memberRole, ...rest }) => ({ ...rest, role: - isWorkspaceAdmin && isSharedCredentialType(rest.type) ? 'admin' : (memberRole ?? 'member'), + (rest.type === 'env_personal' && rest.envOwnerUserId === session.user.id) || + (isWorkspaceAdmin && isSharedCredentialType(rest.type)) + ? 'admin' + : (memberRole ?? 'member'), })) return NextResponse.json({ credentials }) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index f78dd26b928..f23f0d06e07 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -816,6 +816,34 @@ describe('Function Execute API Route', () => { expect(data.__resolvedSecretNames).toEqual(['API_KEY']) }) + it('does not report a reference when validation rejects before code resolution', async () => { + const response = await POST( + createMockRequest( + 'POST', + { + code: 'return {{API_KEY}}', + envVars: { API_KEY: 'secret-value' }, + outputs: { + files: Array.from({ length: 21 }, (_, index) => ({ + path: `files/output-${index}.json`, + sandboxPath: `/home/user/output-${index}.json`, + })), + }, + }, + { + 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1', + } + ) + ) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('Too many sandbox output files requested') + expect(data.__resolvedSecretNames).toEqual([]) + expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() + expect(mockExecuteInSandbox).not.toHaveBeenCalled() + }) + it('reports only successful references sourced from scoped environment variables', async () => { const envResponse = await POST( createMockRequest( diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 4f72d777a38..3204731376c 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -55,6 +55,7 @@ import { getWorkflowById } from '@/lib/workflows/utils' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { formatLiteralForCode } from '@/executor/utils/code-formatting' +import { createCodeEnvVarPattern } from '@/executor/utils/code-secret-references' import { createEnvVarPattern, createReferencePattern, @@ -1595,7 +1596,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (lang === CodeLanguage.Shell) { // For shell, env vars are injected as OS env vars via shellEnvs. // Replace {{VAR}} placeholders with $VAR so the shell can access them natively. - resolvedCode = code.replace(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g, (_match, name) => { + resolvedCode = code.replace(createCodeEnvVarPattern(lang), (_match, name) => { if (Object.hasOwn(envVars, name)) { routeContext?.resolvedSecretNames.add(name) } diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts index 70f2c1938db..b052d2cfa79 100644 --- a/apps/sim/app/api/mothership/execute/route.test.ts +++ b/apps/sim/app/api/mothership/execute/route.test.ts @@ -224,6 +224,38 @@ describe('mothership private trace provenance transport', () => { ) }) + it('keeps headless secret policy server-only', async () => { + mockRunHeadlessCopilotLifecycle.mockImplementation( + async (payload: Record, options: CopilotLifecycleOptions) => { + expect(payload).not.toHaveProperty('secretScope') + expect(payload).not.toHaveProperty('mountedSecrets') + expect(options).toMatchObject({ + secretActorUserId: 'user-1', + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: ['API_KEY'], + }, + }) + return successResult() + } + ) + + const response = await POST( + createMockRequest( + 'POST', + { + ...requestBody, + secretScope: 'selected', + mountedSecrets: ['API_KEY'], + }, + { Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' }, + 'http://localhost:3000/api/mothership/execute' + ) + ) + + expect(response.status).toBe(200) + }) + it('keeps execution functional and fails trace provenance closed when catalog setup fails', async () => { mockGetPersonalAndWorkspaceEnv.mockRejectedValueOnce(new Error('catalog unavailable')) mockRunHeadlessCopilotLifecycle.mockImplementation( @@ -296,9 +328,7 @@ describe('mothership private trace provenance transport', () => { it('returns encrypted provenance on a marker-gated successful request', async () => { mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { - expect(options.environmentContext?.decryptedEnvVars).toEqual({ - API_KEY: 'secret-value', - }) + expect(options.environmentContext).not.toHaveProperty('decryptedEnvVars') expect(options.environmentContext?.resolvedSecretTraceRegistry).toBeDefined() expect(options.resolvedSecretTraceRegistry).toBeUndefined() activateSecret(options) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index f12242538c2..c3e9d23f77a 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -22,6 +22,7 @@ import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/co import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' import type { StreamEvent } from '@/lib/copilot/request/types' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' @@ -166,7 +167,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workflowId, executionId, userMetadata, + secretScope, + mountedSecrets, } = validation.data.body + const secretMountPolicy = normalizeSecretMountPolicy({ secretScope, mountedSecrets }) /** * Bind actor attribution to the authenticated identity. The executor mints @@ -374,6 +378,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { interactive: false, abortSignal: lifecycleAbortController.signal, billingAttribution, + ...(userPermission ? { userPermission } : {}), + secretActorUserId: userId, + secretMountPolicy, environmentContext, ...(!environmentContext && resolvedSecretTraceRegistry ? { resolvedSecretTraceRegistry } diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index 56949815250..6c3f6248c4c 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -205,12 +205,21 @@ export const PUT = withRouteHandler( time: validatedBody.time, endsAt: validatedBody.endsAt, contexts: validatedBody.contexts, + secretScope: validatedBody.secretScope, + mountedSecrets: validatedBody.mountedSecrets, request, }) if (!updateResult.success) { return NextResponse.json( { error: updateResult.error || 'Failed to update schedule' }, - { status: updateResult.errorCode === 'validation' ? 400 : 500 } + { + status: + updateResult.errorCode === 'forbidden' + ? 403 + : updateResult.errorCode === 'validation' + ? 400 + : 500, + } ) } diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index ca25b2fe946..f2741fc4908 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -226,6 +226,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { endsAt, startDate, contexts, + secretScope, + mountedSecrets, } = parsed.data.body const permission = await verifyWorkspaceMembership(session.user.id, workspaceId) @@ -248,6 +250,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { endsAt, startDate, contexts, + secretScope, + mountedSecrets, request: req, }) if (!result.success || !result.schedule) { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index c55a9c1030d..a9450ed2884 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -9,6 +9,7 @@ import { executionPreprocessingMockFns, hybridAuthMockFns, loggingSessionMock, + loggingSessionMockFns, queueTableRows, requestUtilsMockFns, resetDbChainMock, @@ -32,8 +33,13 @@ const { mockExecuteWorkflowCore, mockGenerateId, mockGetWorkspaceBillingSettings, + mockGetAsyncToolCall, + mockGetRunSegment, + mockCreateExecutionEventWriter, + mockFlushExecutionStreamReplayBuffer, mockHandlePostExecutionPauseState, mockHasDurableExecutionOwner, + mockInitializeExecutionStreamMeta, mockReleaseExecutionIdClaim, mockReleaseExecutionSlot, mockRequireBillingAttributionHeader, @@ -50,8 +56,13 @@ const { mockExecuteWorkflowCore: vi.fn(), mockGenerateId: vi.fn(() => 'execution-123'), mockGetWorkspaceBillingSettings: vi.fn(), + mockGetAsyncToolCall: vi.fn(), + mockGetRunSegment: vi.fn(), + mockCreateExecutionEventWriter: vi.fn(), + mockFlushExecutionStreamReplayBuffer: vi.fn(), mockHandlePostExecutionPauseState: vi.fn(), mockHasDurableExecutionOwner: vi.fn(), + mockInitializeExecutionStreamMeta: vi.fn(), mockReleaseExecutionIdClaim: vi.fn(), mockReleaseExecutionSlot: vi.fn(), mockRequireBillingAttributionHeader: vi.fn(), @@ -102,6 +113,18 @@ vi.mock('@/lib/workflows/executor/execution-id-claim', () => ({ releaseExecutionIdClaim: mockReleaseExecutionIdClaim, })) +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + getAsyncToolCall: mockGetAsyncToolCall, + getRunSegment: mockGetRunSegment, +})) + +vi.mock('@/lib/execution/event-buffer', () => ({ + createExecutionEventWriter: mockCreateExecutionEventWriter, + flushExecutionStreamReplayBuffer: mockFlushExecutionStreamReplayBuffer, + initializeExecutionStreamMeta: mockInitializeExecutionStreamMeta, + LIVE_ONLY_EXECUTION_EVENT_TYPES: new Set(), +})) + vi.mock('@/lib/execution/payloads/store', () => ({ storeLargeValue: vi.fn(async (_value, _json, size: number) => ({ __simLargeValueRef: true, @@ -174,6 +197,24 @@ function createSessionReplayRequest(executionId: string): NextRequest { ) } +function createBoundCopilotExecutionRequest(overrides: Record = {}): NextRequest { + return createMockRequest( + 'POST', + { + input: { hello: 'world' }, + stream: true, + isClientSession: true, + triggerType: 'copilot', + copilotToolCallId: 'copilot-tool-1', + ...overrides, + }, + { + 'Content-Type': 'application/json', + Cookie: 'session=value', + } + ) +} + interface ExecutionCallerCase { caseName: string authResult: Record @@ -290,6 +331,18 @@ describe('workflow execute async route', () => { token: `token-${executionId}`, })) mockHasDurableExecutionOwner.mockResolvedValue(false) + mockGetAsyncToolCall.mockReset().mockResolvedValue({ + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + }) + mockGetRunSegment.mockReset().mockResolvedValue({ + id: 'copilot-run-1', + userId: 'session-user-1', + workflowId: 'workflow-1', + }) requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-12345678') workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValue(false) @@ -328,7 +381,7 @@ describe('workflow execute async route', () => { }) workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState.mockResolvedValue(null) workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null) - mockExecuteWorkflowCore.mockResolvedValue({ + mockExecuteWorkflowCore.mockReset().mockResolvedValue({ success: true, status: 'completed', output: { ok: true }, @@ -339,6 +392,140 @@ describe('workflow execute async route', () => { }, }) mockHandlePostExecutionPauseState.mockResolvedValue(undefined) + mockInitializeExecutionStreamMeta.mockReset().mockResolvedValue(true) + mockFlushExecutionStreamReplayBuffer.mockReset().mockResolvedValue(true) + mockCreateExecutionEventWriter.mockReset().mockReturnValue({ + write: vi.fn(async (event: unknown) => ({ event, eventId: '1' })), + writeTerminal: vi.fn(async (event: unknown) => ({ event, eventId: '2' })), + close: vi.fn().mockResolvedValue(undefined), + }) + loggingSessionMockFns.mockWaitForPostExecution.mockReset().mockResolvedValue(undefined) + }) + + it('binds a Copilot workflow tool only to its server log and waits before terminal SSE', async () => { + let releasePostExecution: (() => void) | undefined + loggingSessionMockFns.mockWaitForPostExecution.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePostExecution = resolve + }) + ) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + const bodyPromise = response.text() + + await vi.waitFor(() => { + expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalledTimes(1) + }) + let streamCompleted = false + void bodyPromise.then(() => { + streamCompleted = true + }) + await Promise.resolve() + + expect(response.status).toBe(200) + expect(streamCompleted).toBe(false) + expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).toHaveBeenCalledWith({ + executionId: 'execution-123', + requestId: 'req-12345678', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'copilot-tool-1', + }) + const executionArgs = mockExecuteWorkflowCore.mock.calls[0][0] + expect(executionArgs).not.toHaveProperty('copilotToolCallId') + expect(executionArgs.snapshot.metadata).not.toHaveProperty('copilotToolCallId') + + releasePostExecution?.() + const body = await bodyPromise + expect(body).toContain('execution:completed') + }) + + it.each([ + [ + 'terminal tool row', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'completed', + }, + { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + ], + [ + 'different workflow target', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-2' }, + status: 'running', + }, + { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + ], + [ + 'different execution actor', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + }, + { id: 'copilot-run-1', userId: 'other-user', workflowId: 'workflow-1' }, + ], + ])('rejects a Copilot binding owned by a %s', async (_caseName, toolCall, run) => { + mockGetAsyncToolCall.mockResolvedValueOnce(toolCall) + mockGetRunSegment.mockResolvedValueOnce(run) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(403) + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled() + }) + + it('rejects Copilot workflow bindings outside the interactive SSE surface', async () => { + const response = await POST(createBoundCopilotExecutionRequest({ stream: false }), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(400) + expect(mockGetAsyncToolCall).not.toHaveBeenCalled() + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'cancelled', + { + success: false, + status: 'cancelled', + output: {}, + logs: [], + metadata: { duration: 1 }, + }, + ], + ['error', new Error('execution failed')], + ])('waits for bound post-execution work on %s terminal paths', async (_caseName, outcome) => { + if (outcome instanceof Error) { + mockExecuteWorkflowCore.mockRejectedValueOnce(outcome) + } else { + mockExecuteWorkflowCore.mockResolvedValueOnce(outcome) + } + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + await response.text() + + expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalledTimes(1) }) it('reuses raw workflow input by execution ID without returning it to the client', async () => { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index d9712918d9e..cb3db039d3a 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -19,6 +19,9 @@ import { type BillingAttributionSnapshot, requireBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' +import { ASYNC_TOOL_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { getAsyncToolCall, getRunSegment } from '@/lib/copilot/async-runs/repository' +import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types' @@ -145,6 +148,27 @@ const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' +async function isValidCopilotWorkflowToolBinding(params: { + toolCallId: string + userId: string + workflowId: string +}): Promise { + const toolCall = await getAsyncToolCall(params.toolCallId) + if ( + !toolCall || + !isWorkflowToolName(toolCall.toolName) || + (toolCall.status !== ASYNC_TOOL_STATUS.pending && toolCall.status !== ASYNC_TOOL_STATUS.running) + ) { + return false + } + + const run = await getRunSegment(toolCall.runId) + return ( + run?.userId === params.userId && + resolveWorkflowToolTargetId(toolCall.args, run.workflowId) === params.workflowId + ) +} + function createExecutionJsonResponse( body: Record, init: ResponseInit | undefined, @@ -730,6 +754,7 @@ async function handleExecutePost( workflowStateOverride, deploymentVersionId: admittedDeploymentVersionId, executionId: rawBodyExecutionId, + copilotToolCallId, triggerBlockId: parsedTriggerBlockId, startBlockId, stopAfterBlockId, @@ -737,6 +762,10 @@ async function handleExecutePost( parentWorkspaceId, } = validation.data const triggerBlockId = parsedTriggerBlockId ?? startBlockId + const streamHeader = req.headers.get('X-Stream-Response') === 'true' + const enableSSE = streamHeader || streamParam === true + const executionModeHeader = req.headers.get('X-Execution-Mode') + const isAsyncMode = executionModeHeader === 'async' if (admittedDeploymentVersionId && !isMcpBridgeRequest) { return NextResponse.json( { error: 'deploymentVersionId is reserved for internal MCP execution' }, @@ -786,6 +815,20 @@ async function handleExecutePost( ) } + if ( + copilotToolCallId && + (auth.authType !== AuthType.SESSION || + !isClientSession || + triggerType !== 'copilot' || + !enableSSE || + isAsyncMode) + ) { + return NextResponse.json( + { error: 'Copilot tool execution binding is invalid for this request' }, + { status: 400 } + ) + } + if (auth.authType === 'api_key') { if (isClientSession) { return NextResponse.json( @@ -900,6 +943,7 @@ async function handleExecutePost( triggerBlockId: _triggerBlockId, stopAfterBlockId: _stopAfterBlockId, runFromBlock: _runFromBlock, + copilotToolCallId: _copilotToolCallId, workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth parentWorkspaceId: _parentWorkspaceId, ...rest @@ -916,10 +960,6 @@ async function handleExecutePost( const shouldUseDraftState = isPublicApiAccess ? false : (useDraftState ?? auth.authType === AuthType.SESSION) - const streamHeader = req.headers.get('X-Stream-Response') === 'true' - const enableSSE = streamHeader || streamParam === true - const executionModeHeader = req.headers.get('X-Execution-Mode') - const isAsyncMode = executionModeHeader === 'async' const requiresWriteExecutionAccess = Boolean( useDraftState || workflowStateOverride || rawRunFromBlock ) @@ -1027,6 +1067,20 @@ async function handleExecutePost( ) } + if ( + copilotToolCallId && + !(await isValidCopilotWorkflowToolBinding({ + toolCallId: copilotToolCallId, + userId, + workflowId, + })) + ) { + return NextResponse.json( + { error: 'Copilot workflow tool binding was not found' }, + { status: 403 } + ) + } + if (inputFromExecutionId) { const { getExecutionInputForWorkflow } = await import( '@/lib/workflows/executor/execution-state' @@ -1100,6 +1154,16 @@ async function handleExecutePost( loggingTriggerType, requestId ) + if (copilotToolCallId) { + loggingSession.setTrustedExecutionCorrelation({ + executionId, + requestId, + source: 'workflow', + workflowId, + triggerType, + copilotToolCallId, + }) + } /** The pre-fetched record avoids a redundant initial workflow lookup. */ const preprocessResult = await preprocessExecution({ @@ -1654,6 +1718,13 @@ async function handleExecutePost( const stream = new ReadableStream({ async start(controller) { let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null + let postExecutionAwaited = false + + const awaitBoundCopilotPostExecution = async () => { + if (!copilotToolCallId || postExecutionAwaited) return + await loggingSession.waitForPostExecution() + postExecutionAwaited = true + } registerManualExecutionAborter(executionId, timeoutController.abort) isManualAbortRegistered = true @@ -2029,6 +2100,8 @@ async function handleExecutePost( runFromBlock: resolvedRunFromBlock, }) + await awaitBoundCopilotPostExecution() + await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) /** @@ -2167,6 +2240,7 @@ async function handleExecutePost( ) } } catch (error: unknown) { + await awaitBoundCopilotPostExecution() const isTimeout = isTimeoutError(error) || timeoutController.isTimedOut() const errorMessage = isTimeout ? getTimeoutErrorMessage(error, timeoutController.timeoutMs) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts index 759abd0b1e5..7f0f121d319 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts @@ -4,12 +4,17 @@ import { authMockFns, createMockRequest, environmentUtilsMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetWorkspaceById, mockGetUserEntityPermissions, mockGetWorkspaceEnvKeyAdminAccess } = - vi.hoisted(() => ({ - mockGetWorkspaceById: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), - })) +const { + mockGetPersonalEnvKeyRawAccess, + mockGetWorkspaceById, + mockGetUserEntityPermissions, + mockGetWorkspaceEnvKeyAdminAccess, +} = vi.hoisted(() => ({ + mockGetPersonalEnvKeyRawAccess: vi.fn(), + mockGetWorkspaceById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), +})) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceById: mockGetWorkspaceById, @@ -19,6 +24,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ const mockGetPersonalAndWorkspaceEnv = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv vi.mock('@/lib/credentials/environment', () => ({ + getPersonalEnvKeyRawAccess: mockGetPersonalEnvKeyRawAccess, getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, createWorkspaceEnvCredentials: vi.fn(), deleteWorkspaceEnvCredentials: vi.fn(), @@ -47,9 +53,14 @@ describe('GET /api/workspaces/[id]/environment', () => { mockGetWorkspaceById.mockResolvedValue({ id: WORKSPACE_ID }) mockGetPersonalAndWorkspaceEnv.mockResolvedValue({ workspaceDecrypted: { OPENAI_API_KEY: 'sk-secret', DATABASE_URL: 'postgres://secret' }, - personalDecrypted: { PERSONAL: { value: 'p' } }, + personalDecrypted: { PERSONAL: 'personal-secret', SHARED_PERSONAL: 'shared-secret' }, + personalOwners: { PERSONAL: 'u-1', SHARED_PERSONAL: 'owner-2' }, conflicts: [], }) + mockGetPersonalEnvKeyRawAccess.mockResolvedValue({ + ownedKeys: new Set(['PERSONAL']), + adminKeys: new Set(), + }) }) it('returns 401 when the caller has no workspace permission', async () => { @@ -116,7 +127,7 @@ describe('GET /api/workspaces/[id]/environment', () => { expect(body.data.workspace.DATABASE_URL).toBe('') }) - it('always returns personal values untouched', async () => { + it('reveals own personal values and masks shared personal values without an admin grant', async () => { mockGetUserEntityPermissions.mockResolvedValue('read') mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ adminKeys: new Set(), @@ -125,6 +136,25 @@ describe('GET /api/workspaces/[id]/environment', () => { const { body } = await callGet() - expect(body.data.personal).toEqual({ PERSONAL: { value: 'p' } }) + expect(body.data.personal).toEqual({ PERSONAL: 'personal-secret', SHARED_PERSONAL: '' }) + }) + + it('reveals shared personal values to an active credential admin', async () => { + mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(), + knownKeys: new Set(['OPENAI_API_KEY', 'DATABASE_URL']), + }) + mockGetPersonalEnvKeyRawAccess.mockResolvedValue({ + ownedKeys: new Set(['PERSONAL']), + adminKeys: new Set(['SHARED_PERSONAL']), + }) + + const { body } = await callGet() + + expect(body.data.personal).toEqual({ + PERSONAL: 'personal-secret', + SHARED_PERSONAL: 'shared-secret', + }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index f7aad4aba15..98194c979b0 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -18,6 +18,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceEnvCredentials, deleteWorkspaceEnvCredentials, + getPersonalEnvKeyRawAccess, getWorkspaceEnvKeyAdminAccess, } from '@/lib/credentials/environment' import { @@ -74,6 +75,32 @@ async function maskWorkspaceEnvForViewer({ return masked } +async function maskPersonalEnvForViewer({ + personalDecrypted, + personalOwners, + workspaceId, + userId, +}: { + personalDecrypted: Record + personalOwners: Record + workspaceId: string + userId: string +}): Promise> { + const personalKeys = Object.keys(personalDecrypted) + const { ownedKeys, adminKeys } = await getPersonalEnvKeyRawAccess({ + workspaceId, + personalOwners, + userId, + }) + + return Object.fromEntries( + personalKeys.map((key) => [ + key, + ownedKeys.has(key) || adminKeys.has(key) ? personalDecrypted[key] : '', + ]) + ) +} + export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -98,10 +125,8 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv( - userId, - workspaceId - ) + const { workspaceDecrypted, personalDecrypted, personalOwners, conflicts } = + await getPersonalAndWorkspaceEnv(userId, workspaceId) const workspace = await maskWorkspaceEnvForViewer({ workspaceDecrypted, @@ -109,12 +134,18 @@ export const GET = withRouteHandler( userId, permission, }) + const personal = await maskPersonalEnvForViewer({ + personalDecrypted, + personalOwners, + workspaceId, + userId, + }) return NextResponse.json( { data: { workspace, - personal: personalDecrypted, + personal, conflicts, }, }, diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts new file mode 100644 index 00000000000..a3eebf6272c --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserEntityPermissions, mockHasWorkspaceInboxAccess } = vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), + mockHasWorkspaceInboxAccess: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceInboxAccess: mockHasWorkspaceInboxAccess, +})) + +vi.mock('@/lib/mothership/inbox/lifecycle', () => ({ + disableInbox: vi.fn(), + enableInbox: vi.fn(), + updateInboxAddress: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +import { PATCH } from '@/app/api/workspaces/[id]/inbox/route' + +const context = { params: Promise.resolve({ id: 'workspace-1' }) } + +describe('Inbox config secret policy', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockHasWorkspaceInboxAccess.mockResolvedValue(true) + }) + + it('updates policy without requiring an inbox lifecycle mutation', async () => { + queueTableRows(schemaMock.workspace, [ + { + inboxEnabled: true, + inboxAddress: 'tasks@example.com', + inboxProviderId: 'provider-1', + inboxSecretScope: 'all', + inboxMountedSecrets: [], + }, + ]) + + const response = await PATCH( + createMockRequest( + 'PATCH', + { secretScope: 'selected', mountedSecrets: [' B ', 'A', 'B'] }, + undefined, + 'http://localhost:3000/api/workspaces/workspace-1/inbox' + ), + context + ) + + const body = await response.json() + expect({ status: response.status, body }).toMatchObject({ + status: 200, + body: { + enabled: true, + address: 'tasks@example.com', + secretScope: 'selected', + mountedSecrets: ['B', 'A'], + }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + inboxSecretScope: 'selected', + inboxMountedSecrets: ['B', 'A'], + }) + ) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index bbaa2594986..0bcc27b959d 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -7,6 +7,7 @@ import { updateInboxConfigContract } from '@/lib/api/contracts/inbox' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -31,6 +32,8 @@ export const GET = withRouteHandler( .select({ inboxEnabled: workspace.inboxEnabled, inboxAddress: workspace.inboxAddress, + inboxSecretScope: workspace.inboxSecretScope, + inboxMountedSecrets: workspace.inboxMountedSecrets, }) .from(workspace) .where(eq(workspace.id, workspaceId)) @@ -68,6 +71,10 @@ export const GET = withRouteHandler( return NextResponse.json({ enabled: ws.inboxEnabled, address: ws.inboxAddress, + ...normalizeSecretMountPolicy({ + secretScope: ws.inboxSecretScope, + mountedSecrets: ws.inboxMountedSecrets, + }), entitled, taskStats: stats, }) @@ -92,9 +99,57 @@ export const PATCH = withRouteHandler( const body = parsed.data.body try { + const [current] = await db + .select({ + inboxEnabled: workspace.inboxEnabled, + inboxAddress: workspace.inboxAddress, + inboxProviderId: workspace.inboxProviderId, + inboxSecretScope: workspace.inboxSecretScope, + inboxMountedSecrets: workspace.inboxMountedSecrets, + }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + if (!current) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + const hasPolicyUpdate = body.secretScope !== undefined || body.mountedSecrets !== undefined + const secretMountPolicy = normalizeSecretMountPolicy({ + secretScope: body.secretScope ?? current.inboxSecretScope, + mountedSecrets: body.mountedSecrets ?? current.inboxMountedSecrets, + }) + const persistPolicy = async () => { + if (!hasPolicyUpdate) return + await db + .update(workspace) + .set({ + inboxSecretScope: secretMountPolicy.secretScope, + inboxMountedSecrets: secretMountPolicy.mountedSecrets, + updatedAt: new Date(), + }) + .where(eq(workspace.id, workspaceId)) + } + if (body.enabled === false) { await disableInbox(workspaceId) - return NextResponse.json({ enabled: false, address: null }) + await persistPolicy() + return NextResponse.json({ + enabled: false, + address: null, + providerId: null, + ...secretMountPolicy, + }) + } + + if (body.enabled === undefined && body.username === undefined && hasPolicyUpdate) { + await persistPolicy() + return NextResponse.json({ + enabled: current.inboxEnabled, + address: current.inboxAddress, + providerId: current.inboxProviderId, + ...secretMountPolicy, + }) } if (!(await hasWorkspaceInboxAccess(workspaceId))) { @@ -102,21 +157,18 @@ export const PATCH = withRouteHandler( } if (body.enabled === true) { - const [current] = await db - .select({ inboxEnabled: workspace.inboxEnabled }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - if (current?.inboxEnabled) { + if (current.inboxEnabled) { return NextResponse.json({ error: 'Inbox is already enabled' }, { status: 409 }) } const config = await enableInbox(workspaceId, { username: body.username }) - return NextResponse.json(config) + await persistPolicy() + return NextResponse.json({ ...config, ...secretMountPolicy }) } if (body.username) { const config = await updateInboxAddress(workspaceId, body.username) - return NextResponse.json(config) + await persistPolicy() + return NextResponse.json({ ...config, ...secretMountPolicy }) } return NextResponse.json({ error: 'No valid update provided' }, { status: 400 }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx index 830634c4a9d..36f975137c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react' import { ChevronDown, Chip, + ChipTag, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -16,6 +17,7 @@ import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { copilotToolPermissionContract } from '@/lib/api/contracts/copilot' +import { getToolSecretMountNames } from '@/lib/copilot/tools/secret-mount' import { generalSettingsKeys } from '@/hooks/queries/general-settings' import { useToolPermissionStore } from '@/stores/tool-permission/store' @@ -128,6 +130,8 @@ export function ToolPermissionCard({ ) const preview = argsPreview(params) + const mountedSecretNames = getToolSecretMountNames(toolName, params) + const mountsSecrets = mountedSecretNames.length > 0 const busy = submitting !== null || isSubmitted if (expired) { @@ -169,26 +173,39 @@ export function ToolPermissionCard({ void submit('allow', [toolCallId])}> Allow - - - - Don't ask again - - - - void submit('allow_chat', [toolCallId])}> - For this chat - - void submit('always_allow', [toolCallId])}> - For every chat - - - + {!mountsSecrets && ( + + + + Don't ask again + + + + void submit('allow_chat', [toolCallId])}> + For this chat + + void submit('always_allow', [toolCallId])}> + For every chat + + + + )} void submit('skip', [toolCallId])}> Skip + {mountsSecrets && ( +
+ Secrets + {mountedSecretNames.map((name) => ( + + {`{{${name}}}`} + + ))} +
+ )} + {showBulkActions && (
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx index f7bf16ba921..b9afc65ea72 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx @@ -16,6 +16,7 @@ interface TaskContextMenuProps { onClose: () => void /** The right-clicked task; its status decides which actions render. */ task: ScheduledTask | null + canEdit: boolean onEdit: () => void /** Opens a new-task modal pre-filled from this task. */ onDuplicate: () => void @@ -37,6 +38,7 @@ export function TaskContextMenu({ position, onClose, task, + canEdit, onEdit, onDuplicate, onPause, @@ -72,10 +74,12 @@ export function TaskContextMenu({ > {isUpcoming ? ( <> - - - Edit - + {canEdit && ( + + + Edit + + )} {canPauseResume && (task?.disabled ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx index 2666b89186e..b52cb043e54 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx @@ -39,8 +39,8 @@ interface TaskDetailsModalProps { } /** - * Read-only record modal for tasks that are running or already finished — - * pending tasks open the edit `TaskModal` instead. Three plaintext fields: + * Read-only record modal for tasks that are running, finished, or owned by + * another execution actor. Three plaintext fields: * Status and the run time as copy fields, the prompt as a view-only chip editor. */ export function TaskDetailsModal({ task, onClose }: TaskDetailsModalProps) { diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx new file mode 100644 index 00000000000..a4b14c8adb7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx @@ -0,0 +1,67 @@ +'use client' + +import { ChipModalField, ChipModalSeparator, ChipSelect } from '@sim/emcn' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import { useRawMountableSecretOptions } from '@/hooks/queries/secret-mount-options' + +const SECRET_SCOPE_OPTIONS = [ + { value: 'all', label: 'All secrets' }, + { value: 'selected', label: 'Selected secrets' }, +] + +interface SecretAccessSectionProps extends SecretMountPolicy { + workspaceId: string + onChange: (policy: SecretMountPolicy) => void +} + +export function SecretAccessSection({ + workspaceId, + secretScope, + mountedSecrets, + onChange, +}: SecretAccessSectionProps) { + const { options, isPending } = useRawMountableSecretOptions(workspaceId) + + return ( +
+ +
+ + + onChange({ + secretScope: value === 'selected' ? 'selected' : 'all', + mountedSecrets, + }) + } + options={SECRET_SCOPE_OPTIONS} + /> + + + {secretScope === 'selected' && ( + + + onChange({ secretScope: 'selected', mountedSecrets: values }) + } + disabled={isPending} + /> + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx index de88be597bf..fad48a1bd31 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx @@ -13,12 +13,17 @@ import { import { Calendar } from '@sim/emcn/icons' import { format } from 'date-fns' import { useParams } from 'next/navigation' +import { + DEFAULT_SECRET_MOUNT_POLICY, + type SecretMountPolicy, +} from '@/lib/copilot/secret-mount-policy' import { wallClockNow, zonedWallClockToUtc } from '@/lib/core/utils/timezone' import { PromptEditor, usePromptEditor, } from '@/app/workspace/[workspaceId]/home/components/user-input/components' import { RecurrenceSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section' +import { SecretAccessSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section' import type { CalendarSlot } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar' import { DEFAULT_RECURRENCE, @@ -69,7 +74,7 @@ function defaultLaunch( } /** The data a task create or edit captures. */ -export interface TaskDraft { +export interface TaskDraft extends SecretMountPolicy { prompt: string /** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */ contexts?: ChatContext[] @@ -80,7 +85,7 @@ export interface TaskDraft { } /** Pre-filled fields shared by the edit and duplicate flows. */ -export interface TaskPrefill { +export interface TaskPrefill extends SecretMountPolicy { prompt: string /** Stored `@`-mention contexts, re-registered so they carry over. */ contexts?: ChatContext[] @@ -223,6 +228,10 @@ function TaskModalContent({ const [recurrence, setRecurrence] = useState( () => source?.recurrence ?? DEFAULT_RECURRENCE ) + const [secretPolicy, setSecretPolicy] = useState(() => ({ + secretScope: source?.secretScope ?? DEFAULT_SECRET_MOUNT_POLICY.secretScope, + mountedSecrets: source?.mountedSecrets ?? DEFAULT_SECRET_MOUNT_POLICY.mountedSecrets, + })) const launchEditedRef = useRef(false) /** * Synchronous mirror of `submitting` that gates {@link handleSubmit}. The @@ -286,6 +295,7 @@ function TaskModalContent({ launchTime, timezone, recurrence, + ...secretPolicy, }) ) .then(() => true) @@ -331,6 +341,7 @@ function TaskModalContent({ /> + maxRuns: fields.maxRuns ?? null, endsAt: fields.endsAt ?? null, contexts: draft.contexts ?? [], + secretScope: draft.secretScope, + mountedSecrets: draft.mountedSecrets, } } @@ -212,6 +216,8 @@ export function useScheduledTasks({ launchTime, timezone: schedule.timezone, recurrence, + secretScope: schedule.secretScope, + mountedSecrets: schedule.mountedSecrets, } }, [schedules] diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx index 8f3410fe014..4d8f1c86cd1 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react' import { Calendar, Plus } from '@sim/emcn/icons' import { useParams } from 'next/navigation' +import { useSession } from '@/lib/auth/auth-client' import type { ResourceAction } from '@/app/workspace/[workspaceId]/components' import { Resource } from '@/app/workspace/[workspaceId]/components' import { ScheduleCalendar } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar' @@ -23,6 +24,7 @@ import { useTimezone } from '@/hooks/queries/general-settings' export function ScheduledTasks() { const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: session } = useSession() const timezone = useTimezone() const calendar = useCalendar(timezone) @@ -32,9 +34,12 @@ export function ScheduledTasks() { ) const tasks = useScheduledTasks({ workspaceId, rangeStart: range.start, rangeEnd: range.end }) - /** Pending tasks open the editable TaskModal; running/finished open the record. */ - const editTask = tasks.selectedTask?.status === 'pending' ? tasks.selectedTask : null - const recordTask = tasks.selectedTask?.status !== 'pending' ? tasks.selectedTask : null + /** Only the execution actor may edit task contents; every other view is read-only. */ + const selectedTaskIsEditable = + tasks.selectedTask?.status === 'pending' && + tasks.selectedTask.sourceUserId === session?.user?.id + const editTask = selectedTaskIsEditable ? tasks.selectedTask : null + const recordTask = tasks.selectedTask && !selectedTaskIsEditable ? tasks.selectedTask : null const editSeed = editTask ? tasks.editSeedFor(editTask) : null const { @@ -183,6 +188,7 @@ export function ScheduledTasks() { position={taskContextMenuPosition} onClose={closeTaskContextMenu} task={contextTask} + canEdit={contextTask?.sourceUserId === session?.user?.id} onEdit={openContextTask} onDuplicate={handleDuplicate} onPause={handlePauseContextTask} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts index 313af910c11..e2c08c48b30 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts @@ -15,6 +15,7 @@ function makeTask(overrides: Partial): ScheduledTask { return { id: 't1', scheduleId: 's1', + sourceUserId: 'user-1', prompt: 'Summarize yesterday', runAt: new Date('2026-06-10T14:30:00.000Z'), timezone: 'UTC', @@ -93,13 +94,18 @@ describe('taskToCalendarEvent', () => { describe('scheduleToTasks', () => { it('renders an active one-time task as a single pending occurrence at its next run', () => { const tasks = scheduleToTasks( - makeRow({ nextRunAt: '2026-06-11T09:00:00.000Z' }), + makeRow({ nextRunAt: '2026-06-11T09:00:00.000Z', sourceUserId: 'creator-1' }), RANGE_START, RANGE_END, NOW ) expect(tasks).toHaveLength(1) - expect(tasks[0]).toMatchObject({ scheduleId: 's1', status: 'pending', recurring: false }) + expect(tasks[0]).toMatchObject({ + scheduleId: 's1', + sourceUserId: 'creator-1', + status: 'pending', + recurring: false, + }) expect(tasks[0].runAt.toISOString()).toBe('2026-06-11T09:00:00.000Z') }) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts index 141a5d7c32c..0361e6325b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts @@ -20,6 +20,8 @@ export interface ScheduledTask { id: string /** The persisted schedule id, used to edit or delete the task. */ scheduleId: string + /** The user whose authority executes the task and who may edit its contents. */ + sourceUserId: string | null /** The instruction Sim runs. Doubles as the calendar title. */ prompt: string /** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */ @@ -100,6 +102,7 @@ export function scheduleToTasks( const contexts = (row.contexts ?? undefined) as unknown as ChatContext[] | undefined const base = { scheduleId: row.id, + sourceUserId: row.sourceUserId, prompt: row.prompt ?? '', contexts, timezone: row.timezone, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx index d34e1036307..b9491208fe9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx @@ -11,6 +11,8 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + ChipSelect, + Label, Tooltip, useCopyToClipboard, } from '@sim/emcn' @@ -24,7 +26,16 @@ import { useInboxSenders, useRemoveInboxSender, useUpdateInboxAddress, + useUpdateInboxSecretPolicy, } from '@/hooks/queries/inbox' +import { useRawMountableSecretOptions } from '@/hooks/queries/secret-mount-options' + +const SECRET_SCOPE_OPTIONS = [ + { value: 'all', label: 'All secrets' }, + { value: 'selected', label: 'Selected secrets' }, +] + +const DROPDOWN_TRIGGER_CLASS = 'w-[240px] flex-shrink-0' export function InboxSettingsTab() { const params = useParams() @@ -33,6 +44,7 @@ export function InboxSettingsTab() { const { data: config } = useInboxConfig(workspaceId) const { data: sendersData, isLoading: sendersLoading } = useInboxSenders(workspaceId) const updateAddress = useUpdateInboxAddress() + const updateSecretPolicy = useUpdateInboxSecretPolicy() const addSender = useAddInboxSender() const removeSender = useRemoveInboxSender() @@ -47,6 +59,11 @@ export function InboxSettingsTab() { const [removeSenderError, setRemoveSenderError] = useState(null) const { copied: copiedAddress, copy } = useCopyToClipboard() + const { options: secretOptions, isPending: secretOptionsPending } = + useRawMountableSecretOptions(workspaceId) + + const secretScope = config?.secretScope ?? 'all' + const mountedSecrets = config?.mountedSecrets ?? [] const handleCopyAddress = useCallback(() => { if (config?.address) void copy(config.address) @@ -228,6 +245,60 @@ export function InboxSettingsTab() {
+ + +
+
+ +
+ + updateSecretPolicy.mutate({ + workspaceId, + secretScope: value === 'selected' ? 'selected' : 'all', + mountedSecrets, + }) + } + options={SECRET_SCOPE_OPTIONS} + disabled={updateSecretPolicy.isPending} + /> +
+
+ + {secretScope === 'selected' && ( +
+ +
+ + updateSecretPolicy.mutate({ + workspaceId, + secretScope: 'selected', + mountedSecrets: values, + }) + } + disabled={secretOptionsPending || updateSecretPolicy.isPending} + /> +
+
+ )} +
+
0 ? { contexts: jobRecord.contexts } : {}), diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index 74e81cb07d8..7bf9e17fe7f 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -1,4 +1,5 @@ import { Blimp } from '@sim/emcn' +import { fetchWorkspaceRawSecretNameOptions } from '@/lib/workflows/subblocks/options' import type { BlockConfig } from '@/blocks/types' import type { ToolResponse } from '@/tools/types' @@ -72,6 +73,31 @@ export const MothershipBlock: BlockConfig = { type: 'skill-input', defaultValue: [], }, + { + id: 'secretScope', + title: 'Secret access', + type: 'dropdown', + mode: 'advanced', + hideFromCopilot: true, + options: [ + { label: 'All secrets', id: 'all' }, + { label: 'Selected secrets', id: 'selected' }, + ], + value: () => 'all', + }, + { + id: 'mountedSecrets', + title: 'Secrets', + type: 'dropdown', + mode: 'advanced', + hideFromCopilot: true, + multiSelect: true, + searchable: true, + preserveLabelCase: true, + options: [], + condition: { field: 'secretScope', value: 'selected' }, + fetchOptions: () => fetchWorkspaceRawSecretNameOptions(), + }, ], tools: { access: [], @@ -91,6 +117,8 @@ export const MothershipBlock: BlockConfig = { }, tools: { type: 'json', description: 'MCP tools available to Sim for this request' }, skills: { type: 'json', description: 'Skills activated for this request' }, + secretScope: { type: 'string', description: 'Secret access mode: all or selected' }, + mountedSecrets: { type: 'json', description: 'Secret names available to Sim code execution' }, }, outputs: { content: { type: 'string', description: 'Generated response content' }, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 716625d7102..10b2917c3c1 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -337,6 +337,8 @@ export interface SubBlockConfig { connectionDroppable?: boolean hidden?: boolean hideFromPreview?: boolean // Hide this subblock from the workflow block preview + /** Excludes server-only lifecycle configuration from Copilot workflow state and schemas. */ + hideFromCopilot?: boolean hideDividerBefore?: boolean // Visually group this field with the preceding visible subblock showWhenEnvSet?: string // Show this subblock only when a named NEXT_PUBLIC_ env var is truthy; comma-separated means any of them hideWhenHosted?: boolean // Hide this subblock when running on hosted sim diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index e267183e144..d37e71b8887 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -440,6 +440,72 @@ describe('BlockExecutor', () => { expect(output).not.toEqual({ content: '' }) }) + it('keeps Sim Chat secret policy in runtime inputs and out of trace inputs', async () => { + const block = createBlock() + block.id = 'mothership-block-1' + block.metadata = { id: BlockType.MOTHERSHIP, name: 'Sim Chat' } + block.config = { + tool: BlockType.MOTHERSHIP, + params: { + prompt: 'Run the task', + secretScope: 'selected', + mountedSecrets: ['OPENAI_API_KEY'], + }, + } + block.privateInputIds = ['secretScope', 'mountedSecrets'] + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const handler: BlockHandler = { + canHandle: () => true, + execute: async (_ctx, _block, inputs) => { + expect(inputs).toMatchObject({ + prompt: 'Run the task', + secretScope: 'selected', + mountedSecrets: ['OPENAI_API_KEY'], + }) + return { content: 'done' } + }, + } + const executor = new BlockExecutor( + [handler], + resolver, + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) + const ctx = createContext(state) + + await executor.execute(ctx, createNode(block), block) + + expect(ctx.blockLogs[0]?.input).toEqual({ prompt: 'Run the task' }) + const { traceSpans } = buildTraceSpans({ + success: true, + output: { content: 'done' }, + logs: ctx.blockLogs, + }) + expect(traceSpans[0]?.input).toEqual({ prompt: 'Run the task' }) + }) + it('projects a resolved secret out of Function syntax-error TraceSpans only', async () => { const secret = 'function-secret-literal-7f3a91' const block = createBlock() diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 105eb517863..b4f90fcc1c7 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -160,7 +160,7 @@ export class BlockExecutor { } if (blockLog) { - blockLog.input = this.sanitizeInputsForLog(inputsForLog, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(inputsForLog, block) } } catch (error) { cleanupSelfReference?.() @@ -300,7 +300,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(inputsForLog, block.metadata?.id), + this.sanitizeInputsForLog(inputsForLog, block), displayOutput, duration, blockLog.startedAt, @@ -413,7 +413,7 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = true blockLog.error = undefined - blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(input, block) blockLog.output = filterOutputForLog(block.metadata?.id || '', softOutput, { block }) } @@ -428,7 +428,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(input, block.metadata?.id), + this.sanitizeInputsForLog(input, block), filterOutputForLog(block.metadata?.id || '', softOutput, { block }), duration, blockLog.startedAt, @@ -480,7 +480,7 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = false blockLog.error = errorMessage - blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(input, block) blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) { @@ -507,7 +507,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(input, block.metadata?.id), + this.sanitizeInputsForLog(input, block), displayOutput, duration, blockLog.startedAt, @@ -631,8 +631,10 @@ export class BlockExecutor { */ private sanitizeInputsForLog( inputs: Record, - blockType?: string + block?: SerializedBlock ): Record { + const blockType = block?.metadata?.id + const privateInputIds = new Set(block?.privateInputIds ?? []) // Custom (deploy-as-block) blocks run via an internal `workflow_executor`; the // baked `workflowId`/`inputMapping` wrapper is plumbing. Log the mapped input // field values (the inputMapping contents) instead. @@ -658,7 +660,8 @@ export class BlockExecutor { SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode' || key === FUNCTION_BLOCK_CONTEXT_VARS_KEY || - key === FUNCTION_BLOCK_DISPLAY_CODE_KEY + key === FUNCTION_BLOCK_DISPLAY_CODE_KEY || + privateInputIds.has(key) ) { continue } diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index c70776aba80..63d4750ba5f 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -377,6 +377,8 @@ describe('MothershipBlockHandler', () => { chatId: 'chat-uuid', messageId: 'message-uuid', requestId: 'request-uuid', + secretScope: 'all', + mountedSecrets: [], workflowId: 'workflow-1', executionId: 'execution-1', }) @@ -443,6 +445,8 @@ describe('MothershipBlockHandler', () => { chatId: 'existing-chat-id', messageId: 'message-uuid', requestId: 'request-uuid', + secretScope: 'all', + mountedSecrets: [], workflowId: 'workflow-1', executionId: 'execution-1', }) diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index b12dea62f66..cbc288f6e44 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -5,6 +5,7 @@ import { BILLING_ATTRIBUTION_HEADER, serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { env } from '@/lib/core/config/env' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' import { readUserFileContent } from '@/lib/execution/payloads/materialization.server' @@ -397,6 +398,10 @@ export class MothershipBlockHandler implements BlockHandler { const chatId = providedConversationId || generateId() const messageId = generateId() const requestId = generateId() + const secretMountPolicy = normalizeSecretMountPolicy({ + secretScope: inputs.secretScope, + mountedSecrets: inputs.mountedSecrets, + }) const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId) const mcpTools = Array.isArray(inputs.tools) ? inputs.tools.filter( @@ -442,6 +447,8 @@ export class MothershipBlockHandler implements BlockHandler { chatId, messageId, requestId, + secretScope: secretMountPolicy.secretScope, + mountedSecrets: secretMountPolicy.mountedSecrets, ...(fileAttachments && { fileAttachments }), ...(mcpTools.length > 0 ? { mcpTools } : {}), ...(skillContexts.length > 0 ? { contexts: skillContexts } : {}), diff --git a/apps/sim/executor/utils/code-secret-references.ts b/apps/sim/executor/utils/code-secret-references.ts new file mode 100644 index 00000000000..3e3bfc19edc --- /dev/null +++ b/apps/sim/executor/utils/code-secret-references.ts @@ -0,0 +1,38 @@ +import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' + +function resolveCodeLanguage(language: unknown): CodeLanguage { + return typeof language === 'string' && isValidCodeLanguage(language) + ? language + : DEFAULT_CODE_LANGUAGE +} + +export function createCodeEnvVarPattern(language?: unknown): RegExp { + return resolveCodeLanguage(language) === CodeLanguage.Shell + ? /\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g + : createEnvVarPattern() +} + +/** + * Extracts only environment references the Function runtime can resolve for the selected language. + * The returned order follows the code, with duplicate names removed after their first occurrence. + */ +export function extractCodeSecretNames(code: unknown, language?: unknown): string[] { + if (typeof code !== 'string') return [] + + const resolvedLanguage = resolveCodeLanguage(language) + const pattern = createCodeEnvVarPattern(resolvedLanguage) + const names: string[] = [] + const seen = new Set() + let match: RegExpExecArray | null + + while ((match = pattern.exec(code)) !== null) { + const name = resolvedLanguage === CodeLanguage.Shell ? match[1] : match[1].trim() + if (name.length > 0 && !seen.has(name)) { + seen.add(name) + names.push(name) + } + } + + return names +} diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.ts b/apps/sim/executor/utils/resolved-secret-content-projection.ts index 8b6ba0d2370..bb04d6e98f2 100644 --- a/apps/sim/executor/utils/resolved-secret-content-projection.ts +++ b/apps/sim/executor/utils/resolved-secret-content-projection.ts @@ -34,6 +34,11 @@ interface ProjectionState { maxBytes: number } +export interface ResolvedSecretContentProjectionOptions { + /** Values already materialized and verified by a boundary-specific projector. */ + isOpaqueSafeObject?: (value: object) => boolean +} + export type ResolvedSecretContentProjection = { safe: true; value: unknown } | { safe: false } class ResolvedSecretContentProjectionError extends Error { @@ -309,6 +314,7 @@ function sanitizeContent( value: unknown, matcher: ResolvedSecretMatcher, state: ProjectionState, + options: ResolvedSecretContentProjectionOptions, depth = 0 ): unknown { visitNode(state, depth) @@ -333,7 +339,11 @@ function sanitizeContent( return sanitized } if (value === undefined) return value - if (typeof value !== 'object' || (!Array.isArray(value) && !isPlainRecord(value))) { + if (typeof value !== 'object') { + throw new ResolvedSecretContentProjectionError('Unsupported secret-bearing content value') + } + if (options.isOpaqueSafeObject?.(value)) return value + if (!Array.isArray(value) && !isPlainRecord(value)) { throw new ResolvedSecretContentProjectionError('Unsupported secret-bearing content value') } if ( @@ -357,7 +367,7 @@ function sanitizeContent( } const sanitized = new Array(value.length) for (const [index, item] of arrayDataEntries(value)) { - sanitized[index] = sanitizeContent(item, matcher, state, depth + 1) + sanitized[index] = sanitizeContent(item, matcher, state, options, depth + 1) } return sanitized } @@ -378,7 +388,7 @@ function sanitizeContent( } sanitizedKeys.add(sanitizedKey) Object.defineProperty(sanitized, sanitizedKey, { - value: sanitizeContent(item, matcher, state, depth + 1), + value: sanitizeContent(item, matcher, state, options, depth + 1), enumerable: true, configurable: true, writable: true, @@ -393,17 +403,23 @@ function sanitizeContent( export function projectResolvedSecretContent( value: unknown, matcher: ResolvedSecretMatcher, - maxBytes = MAX_INLINE_MATERIALIZATION_BYTES + maxBytes = MAX_INLINE_MATERIALIZATION_BYTES, + options: ResolvedSecretContentProjectionOptions = {} ): ResolvedSecretContentProjection { try { return { safe: true, - value: sanitizeContent(value, matcher, { - nodes: 0, - ancestors: new WeakSet(), - outputBytes: 0, - maxBytes, - }), + value: sanitizeContent( + value, + matcher, + { + nodes: 0, + ancestors: new WeakSet(), + outputBytes: 0, + maxBytes, + }, + options + ), } } catch { return { safe: false } diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts index 6b435afff6d..1498c98f45a 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -164,6 +164,34 @@ describe('ResolvedSecretTraceRegistry', () => { ]) }) + it('fails closed while one or more secret activations are pending', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-value' }, + ]) + const completeFirst = registry.beginPendingActivation() + const completeSecond = registry.beginPendingActivation() + + expect(registry.isComplete()).toBe(false) + expect(registry.exportProvenance()).toEqual({ + version: 1, + complete: false, + entries: [], + }) + + registry.recordResolved('API_KEY', 'secret-value') + completeFirst() + expect(registry.isComplete()).toBe(false) + + completeSecond() + completeSecond() + expect(registry.isComplete()).toBe(true) + expect(registry.exportProvenance()).toEqual({ + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-value' }], + }) + }) + it('uses the workspace catalog entry when personal and workspace names conflict', async () => { const registry = await createResolvedSecretTraceRegistry({ personalEncrypted: { SHARED: 'personal-encrypted' }, diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 632a1a56872..7f80a5c5790 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -415,6 +415,7 @@ export class ResolvedSecretTraceRegistry { private readonly activeEntries = new Map() private activeProvenanceEntryBytes = 0 private complete = true + private pendingActivations = 0 private readonly scope?: ResolvedSecretTraceScopeV1 private readonly completeProvenanceEnvelopeBytes: number @@ -540,22 +541,36 @@ export class ResolvedSecretTraceRegistry { } isComplete(): boolean { - return this.complete + return this.complete && this.pendingActivations === 0 } markIncomplete(): void { this.complete = false } + /** + * Makes projections fail closed while an exact runtime substitution is being established. + * The returned completion callback is idempotent so every exit path can safely release it. + */ + beginPendingActivation(): () => void { + this.pendingActivations += 1 + let completed = false + + return () => { + if (completed) return + completed = true + this.pendingActivations = Math.max(0, this.pendingActivations - 1) + } + } + /** Serializes only encrypted active values; plaintext never enters execution state. */ exportProvenance(): ResolvedSecretTraceProvenanceV1 { - const entries = this.complete - ? this.buildProvenanceEntries([...this.activeEntries.values()]) - : [] + const complete = this.isComplete() + const entries = complete ? this.buildProvenanceEntries([...this.activeEntries.values()]) : [] return { version: 1, - complete: this.complete, + complete, entries, ...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}), } @@ -569,7 +584,7 @@ export class ResolvedSecretTraceRegistry { value: unknown, options: ExportResolvedSecretTraceProvenanceForValueOptions = {} ): ResolvedSecretTraceProvenanceV1 { - if (!this.complete) return { version: 1, complete: false, entries: [] } + if (!this.isComplete()) return { version: 1, complete: false, entries: [] } const candidatesByPlaintext = new Map() const sortedActiveEntries = [...this.activeEntries.values()].sort( diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 25daaf06179..a284acaeb12 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -21,7 +21,10 @@ import { } from '@/lib/api/contracts' import { environmentKeys } from '@/hooks/queries/environment' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' -import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-workspace-credentials' +import { + fetchWorkspaceCredentialList, + WORKSPACE_CREDENTIAL_LIST_STALE_TIME, +} from '@/hooks/queries/utils/fetch-workspace-credentials' /** * Key prefix for OAuth credential queries. @@ -29,7 +32,6 @@ import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-worksp */ const OAUTH_CREDENTIALS_KEY = ['oauthCredentials'] as const -export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 export const WORKSPACE_CREDENTIAL_DETAIL_STALE_TIME = 60 * 1000 export const WORKSPACE_CREDENTIAL_MEMBER_LIST_STALE_TIME = 30 * 1000 diff --git a/apps/sim/hooks/queries/inbox.ts b/apps/sim/hooks/queries/inbox.ts index 9577ddeee6a..6b2a7d4f1f8 100644 --- a/apps/sim/hooks/queries/inbox.ts +++ b/apps/sim/hooks/queries/inbox.ts @@ -12,6 +12,7 @@ import { listInboxSendersContract, listInboxTasksContract, removeInboxSenderContract, + type SecretMountPolicyInput, updateInboxConfigContract, } from '@/lib/api/contracts' @@ -140,6 +141,37 @@ export function useUpdateInboxAddress() { }) } +export function useUpdateInboxSecretPolicy() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + workspaceId, + ...policy + }: { workspaceId: string } & Required) => { + return requestJson(updateInboxConfigContract, { + params: { id: workspaceId }, + body: policy, + }) + }, + onMutate: async ({ workspaceId, ...policy }) => { + const queryKey = inboxKeys.config(workspaceId) + await queryClient.cancelQueries({ queryKey }) + const previous = queryClient.getQueryData(queryKey) + if (previous) queryClient.setQueryData(queryKey, { ...previous, ...policy }) + return { previous } + }, + onError: (_error, variables, context) => { + if (context?.previous) { + queryClient.setQueryData(inboxKeys.config(variables.workspaceId), context.previous) + } + }, + onSettled: (_data, _error, variables) => { + return queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) }) + }, + }) +} + export function useAddInboxSender() { const queryClient = useQueryClient() diff --git a/apps/sim/hooks/queries/secret-mount-options.ts b/apps/sim/hooks/queries/secret-mount-options.ts new file mode 100644 index 00000000000..6e678c470d7 --- /dev/null +++ b/apps/sim/hooks/queries/secret-mount-options.ts @@ -0,0 +1,19 @@ +'use client' + +import { useMemo } from 'react' +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' + +export function useRawMountableSecretOptions(workspaceId?: string) { + const query = useWorkspaceCredentials({ workspaceId }) + const options = useMemo( + () => + selectRawMountableSecretNames(query.data ?? []).map((name) => ({ + value: name, + label: name, + })), + [query.data] + ) + + return { options, isPending: query.isPending } +} diff --git a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts index 9fd8efd7b6f..bf1dccfe9d3 100644 --- a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts +++ b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts @@ -1,6 +1,8 @@ import { requestJson } from '@/lib/api/client/request' import { listWorkspaceCredentialsContract, type WorkspaceCredential } from '@/lib/api/contracts' +export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 + /** * Fetches the workspace credential list. * diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index e7136c78dc7..8da3f338c30 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -47,6 +47,7 @@ export const copilotCredentialsQuerySchema = z.object({}) export const copilotConfirmBodySchema = z.object({ toolCallId: z.string().min(1, 'Tool call ID is required'), + executionId: z.string().min(1, 'Execution ID is required').max(255).optional(), status: z.enum( Object.values(ASYNC_TOOL_CONFIRMATION_STATUS) as [ AsyncConfirmationStatus, diff --git a/apps/sim/lib/api/contracts/inbox.ts b/apps/sim/lib/api/contracts/inbox.ts index 34152f3346b..a3e8387c866 100644 --- a/apps/sim/lib/api/contracts/inbox.ts +++ b/apps/sim/lib/api/contracts/inbox.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' export const inboxWorkspaceParamsSchema = z.object({ @@ -17,6 +21,8 @@ export const inboxTaskStatusSchema = z.enum([ export const inboxConfigSchema = z.object({ enabled: z.boolean(), address: z.string().nullable(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, entitled: z.boolean(), taskStats: z.object({ total: z.number(), @@ -32,12 +38,16 @@ export type InboxTaskStatus = z.output export const updateInboxConfigBodySchema = z.object({ enabled: z.boolean().optional(), username: z.string().min(1).max(64).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) export const updateInboxConfigResponseSchema = z.object({ enabled: z.boolean(), address: z.string().nullable(), providerId: z.string().nullable().optional(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, }) export const inboxSenderSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 2001b85b8c2..10ad693347c 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -22,6 +22,7 @@ export * from './permission-groups' export * from './pinned-items' export * from './primitives' export * from './sandboxes' +export * from './secret-mount-policy' export * from './selectors' export * from './skills' export * from './storage-transfer' diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 70a297a486d..275f3c80bf5 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -1,5 +1,9 @@ import { z } from 'zod' import { scheduleContextSchema } from '@/lib/api/contracts/schedules' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' const dateStringSchema = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { @@ -119,6 +123,8 @@ export const mothershipExecuteBodySchema = z.object({ mcpTools: z.array(mothershipExecuteMcpToolSchema).optional(), workflowId: z.string().optional(), executionId: z.string().optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), userMetadata: z .object({ name: z.string().optional(), diff --git a/apps/sim/lib/api/contracts/schedules.ts b/apps/sim/lib/api/contracts/schedules.ts index d4eaf3d5e11..3a707e59232 100644 --- a/apps/sim/lib/api/contracts/schedules.ts +++ b/apps/sim/lib/api/contracts/schedules.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' export const scheduleStatusSchema = z.enum(['active', 'disabled', 'completed']) @@ -74,6 +78,8 @@ export const workflowScheduleRowSchema = z.object({ sourceTaskName: z.string().nullable(), sourceUserId: z.string().nullable(), sourceWorkspaceId: z.string().nullable(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, jobHistory: z.array(z.object({ timestamp: z.string(), summary: z.string() })).nullable(), contexts: z.array(scheduleContextSchema).nullable(), excludedDates: z.array(z.string()).nullable(), @@ -113,6 +119,8 @@ export const createScheduleBodySchema = z endsAt: z.string().optional(), startDate: z.string().optional(), contexts: z.array(scheduleContextSchema).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) .superRefine((body, ctx) => { if (!body.cronExpression && !body.time) { @@ -150,6 +158,8 @@ export const updateScheduleBodySchema = z.object({ maxRuns: z.number().int().positive().nullable().optional(), endsAt: z.string().nullable().optional(), contexts: z.array(scheduleContextSchema).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) export type UpdateScheduleBody = z.input diff --git a/apps/sim/lib/api/contracts/secret-mount-policy.test.ts b/apps/sim/lib/api/contracts/secret-mount-policy.test.ts new file mode 100644 index 00000000000..f1898448ebc --- /dev/null +++ b/apps/sim/lib/api/contracts/secret-mount-policy.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { mountedSecretNamesSchema } from '@/lib/api/contracts/secret-mount-policy' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' + +describe('mountedSecretNamesSchema', () => { + it('accepts the bounded names-only policy shape', () => { + expect(mountedSecretNamesSchema.parse([' API_KEY ', 'name-with-dashes'])).toEqual([ + 'API_KEY', + 'name-with-dashes', + ]) + }) + + it('rejects too many names', () => { + expect(() => + mountedSecretNamesSchema.parse( + Array.from({ length: MAX_SECRET_MOUNT_NAMES + 1 }, (_, index) => `SECRET_${index}`) + ) + ).toThrow() + }) + + it('rejects an overlong name without narrowing the runtime name grammar', () => { + expect(() => + mountedSecretNamesSchema.parse(['S'.repeat(MAX_SECRET_MOUNT_NAME_LENGTH + 1)]) + ).toThrow() + }) +}) diff --git a/apps/sim/lib/api/contracts/secret-mount-policy.ts b/apps/sim/lib/api/contracts/secret-mount-policy.ts new file mode 100644 index 00000000000..d3306ef7ea9 --- /dev/null +++ b/apps/sim/lib/api/contracts/secret-mount-policy.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' + +export const secretMountScopeSchema = z.enum(['all', 'selected']) + +export const mountedSecretNameSchema = z.string().trim().min(1).max(MAX_SECRET_MOUNT_NAME_LENGTH) + +export const mountedSecretNamesSchema = z.array(mountedSecretNameSchema).max(MAX_SECRET_MOUNT_NAMES) + +export const secretMountPolicySchema = z.object({ + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, +}) + +export const secretMountPolicyInputSchema = secretMountPolicySchema.partial() + +export type SecretMountPolicyInput = z.input +export type SecretMountPolicyOutput = z.output diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index c254daf74ff..89a926caf38 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -369,6 +369,7 @@ export const executeWorkflowBodySchema = z.object({ /** Internal MCP bridge pin for calls admitted before a deployment cutover. */ deploymentVersionId: z.string().min(1).optional(), executionId: z.unknown().optional(), + copilotToolCallId: z.string().min(1).max(255).optional(), triggerBlockId: z.string().optional(), startBlockId: z.string().optional(), stopAfterBlockId: z.string().optional(), diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index 50e36eaecd1..120bd502575 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -8,7 +8,9 @@ import { claimCompletedAsyncToolCall, claimPendingAsyncToolCall, completeAsyncToolCall, - markAsyncToolDelivered, + detachAsyncToolCall, + replaceTerminalAsyncToolCallResult, + upsertAsyncToolCall, } from './repository' describe('async tool repository single-row semantics', () => { @@ -17,27 +19,48 @@ describe('async tool repository single-row semantics', () => { resetDbChainMock() }) - it('does not overwrite a delivered row on late completion', async () => { - const deliveredRow = { + it('atomically completes a live row', async () => { + const completedRow = { toolCallId: 'tool-1', - status: 'delivered', + status: 'completed', result: { ok: true }, error: null, } - dbChainMockFns.limit.mockResolvedValueOnce([deliveredRow]) + dbChainMockFns.returning.mockResolvedValueOnce([completedRow]) const result = await completeAsyncToolCall({ toolCallId: 'tool-1', status: 'completed', - result: { ok: false }, + result: { ok: true }, error: null, }) - expect(result).toEqual(deliveredRow) - expect(dbChainMockFns.returning).not.toHaveBeenCalled() + expect(result).toEqual(completedRow) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'completed', + result: { ok: true }, + completedAt: expect.any(Date), + }) + ) + expect(dbChainMockFns.where).toHaveBeenCalled() + }) + + it('returns null when another terminal transition already won', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await completeAsyncToolCall({ + toolCallId: 'tool-1', + status: 'failed', + result: null, + error: 'late error', + }) + + expect(result).toBeNull() + expect(dbChainMockFns.limit).not.toHaveBeenCalled() }) - it('marks a row delivered and clears the claim fields', async () => { + it('atomically detaches a live background call and clears the claim fields', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { toolCallId: 'tool-1', @@ -45,7 +68,7 @@ describe('async tool repository single-row semantics', () => { }, ]) - await markAsyncToolDelivered('tool-1') + await detachAsyncToolCall('tool-1') expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ @@ -54,6 +77,7 @@ describe('async tool repository single-row semantics', () => { claimedAt: null, }) ) + expect(dbChainMockFns.where).toHaveBeenCalled() }) it('claims only completed rows for delivery handoff', async () => { @@ -103,4 +127,55 @@ describe('async tool repository single-row semantics', () => { }) ) }) + + it('replaces only terminal payload fields after trusted projection', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'completed', + result: { output: '{{SECRET}}' }, + }, + ]) + + const result = await replaceTerminalAsyncToolCallResult({ + toolCallId: 'workflow-tool', + status: 'completed', + result: { output: '{{SECRET}}' }, + error: null, + }) + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + status: 'completed', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + status: 'completed', + result: { output: '{{SECRET}}' }, + error: null, + updatedAt: expect.any(Date), + }) + expect(dbChainMockFns.where).toHaveBeenCalled() + }) + + it('keeps the first finalized pending call identity immutable', async () => { + const pendingRow = { + runId: 'run-1', + toolCallId: 'tool-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{FIRST_SECRET}}' }, + status: 'pending', + } + dbChainMockFns.limit.mockResolvedValueOnce([pendingRow]) + + const result = await upsertAsyncToolCall({ + runId: 'run-1', + toolCallId: 'tool-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{SECOND_SECRET}}' }, + status: 'pending', + }) + + expect(result).toEqual(pendingRow) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 257bfdaec2d..1733b1e70a4 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -18,6 +18,7 @@ import { markSpanForError } from '@/lib/copilot/request/otel' import { ASYNC_TOOL_STATUS, type AsyncCompletionData, + type AsyncTerminalStatus, isDeliveredAsyncStatus, isTerminalAsyncStatus, } from './lifecycle' @@ -193,6 +194,7 @@ export async function getRunSegment(runId: string) { id: copilotRuns.id, userId: copilotRuns.userId, status: copilotRuns.status, + workflowId: copilotRuns.workflowId, // Needed to scope an "allow for this chat" decision to its chat. chatId: copilotRuns.chatId, }) @@ -243,6 +245,7 @@ export async function upsertAsyncToolCall(input: { toolName: string args?: Record status?: CopilotAsyncToolStatus + sealedContext?: AsyncCompletionData }) { return withDbSpan( TraceSpan.CopilotAsyncRunsUpsertAsyncToolCall, @@ -257,6 +260,9 @@ export async function upsertAsyncToolCall(input: { async () => { const existing = await getAsyncToolCall(input.toolCallId) const incomingStatus = input.status ?? 'pending' + if (existing?.status === 'pending' && incomingStatus === 'pending') { + return existing + } if ( existing && (isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) && @@ -282,6 +288,7 @@ export async function upsertAsyncToolCall(input: { const now = new Date() const args = sanitizeValueForJsonb(input.args ?? {}) + const sealedContext = sanitizeValueForJsonb(input.sealedContext) const [row] = await db .insert(copilotAsyncToolCalls) .values({ @@ -291,6 +298,7 @@ export async function upsertAsyncToolCall(input: { toolName: input.toolName, args, status: incomingStatus, + ...(sealedContext !== undefined ? { result: sealedContext } : {}), updatedAt: now, }) .onConflictDoUpdate({ @@ -301,6 +309,7 @@ export async function upsertAsyncToolCall(input: { toolName: input.toolName, args, status: incomingStatus, + ...(sealedContext !== undefined ? { result: sealedContext } : {}), updatedAt: now, }, }) @@ -337,7 +346,8 @@ async function markAsyncToolStatus( result?: AsyncCompletionData | null error?: string | null completedAt?: Date | null - } = {} + } = {}, + expectedStatuses?: CopilotAsyncToolStatus[] ) { return withDbSpan( TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, @@ -370,7 +380,14 @@ async function markAsyncToolStatus( completedAt: updates.completedAt, updatedAt: new Date(), }) - .where(eq(copilotAsyncToolCalls.toolCallId, toolCallId)) + .where( + expectedStatuses + ? and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + inArray(copilotAsyncToolCalls.status, expectedStatuses) + ) + : eq(copilotAsyncToolCalls.toolCallId, toolCallId) + ) .returning() return row ?? null @@ -425,27 +442,83 @@ export async function completeAsyncToolCall(input: { result?: AsyncCompletionData | null error?: string | null }) { - const existing = await getAsyncToolCall(input.toolCallId) + return markAsyncToolStatus( + input.toolCallId, + input.status, + { + claimedBy: null, + claimedAt: null, + result: input.result ?? null, + error: input.error ?? null, + completedAt: new Date(), + }, + [ASYNC_TOOL_STATUS.pending, ASYNC_TOOL_STATUS.running] + ) +} - if (!existing) { - logger.warn('completeAsyncToolCall called before pending row existed', { - toolCallId: input.toolCallId, - status: input.status, - }) - return null - } +/** + * Atomically detaches a live client tool after the browser reports that it is + * continuing in the background. Whichever terminal or detach transition wins + * is the only result eligible for publication. + */ +export async function detachAsyncToolCall(toolCallId: string) { + return markAsyncToolStatus( + toolCallId, + ASYNC_TOOL_STATUS.delivered, + { + claimedBy: null, + claimedAt: null, + }, + [ASYNC_TOOL_STATUS.pending, ASYNC_TOOL_STATUS.running] + ) +} - if (isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) { - return existing - } +/** + * Replaces an already-terminal async tool call from a trusted producer. + * + * Client workflow confirmations are persisted structurally first. The live + * Copilot waiter uses this guarded update only after it has restored and + * projected the server-owned workflow result. + */ +export async function replaceTerminalAsyncToolCallResult(input: { + toolCallId: string + status: AsyncTerminalStatus + result: AsyncCompletionData | null + error: string | null +}) { + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: input.toolCallId, + [TraceAttr.CopilotAsyncToolStatus]: input.status, + [TraceAttr.CopilotAsyncToolHasError]: !!input.error, + }, + async () => { + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + status: input.status, + result: sanitizeValueForJsonb(input.result), + error: input.error, + updatedAt: new Date(), + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, input.toolCallId), + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.completed, + ASYNC_TOOL_STATUS.failed, + ASYNC_TOOL_STATUS.cancelled, + ]) + ) + ) + .returning() - return markAsyncToolStatus(input.toolCallId, input.status, { - claimedBy: null, - claimedAt: null, - result: input.result ?? null, - error: input.error ?? null, - completedAt: new Date(), - }) + return row ?? null + } + ) } /** @@ -489,13 +562,6 @@ export async function recordToolPermissionDecision( ) } -export async function markAsyncToolDelivered(toolCallId: string) { - return markAsyncToolStatus(toolCallId, ASYNC_TOOL_STATUS.delivered, { - claimedBy: null, - claimedAt: null, - }) -} - async function listAsyncToolCallsForRun(runId: string) { return withDbSpan( TraceSpan.CopilotAsyncRunsListForRun, diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index a15680a5012..cfd555b6db8 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -205,7 +205,6 @@ describe('handleUnifiedChatPost', () => { workspaceId: 'ws-1', billingAttribution, requestMode: 'agent', - decryptedEnvVars: { API_KEY: 'secret' }, resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), }), }), @@ -248,7 +247,6 @@ describe('handleUnifiedChatPost', () => { workspaceId: 'ws-1', billingAttribution, requestMode: 'agent', - decryptedEnvVars: { API_KEY: 'secret' }, resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), }), }), diff --git a/apps/sim/lib/copilot/environment-context.test.ts b/apps/sim/lib/copilot/environment-context.test.ts index b7d8038a855..e4cee310987 100644 --- a/apps/sim/lib/copilot/environment-context.test.ts +++ b/apps/sim/lib/copilot/environment-context.test.ts @@ -10,7 +10,7 @@ describe('prepareCopilotEnvironmentContext', () => { resetEnvironmentUtilsMock() }) - it('builds runtime env and secret provenance from one workspace-over-personal snapshot', async () => { + it('keeps decrypted values only in the inert provenance registry', async () => { environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ personalEncrypted: { SHARED_SECRET: 'personal-encrypted', @@ -34,11 +34,7 @@ describe('prepareCopilotEnvironmentContext', () => { const context = await prepareCopilotEnvironmentContext('user-1', 'workspace-1') - expect(context.decryptedEnvVars).toEqual({ - SHARED_SECRET: 'workspace-value', - PERSONAL_ONLY: 'personal-only-value', - WORKSPACE_ONLY: 'workspace-only-value', - }) + expect(context).not.toHaveProperty('decryptedEnvVars') expect(context.resolvedSecretTraceRegistry.isComplete()).toBe(true) expect( context.resolvedSecretTraceRegistry.recordResolved('SHARED_SECRET', 'workspace-value') diff --git a/apps/sim/lib/copilot/environment-context.ts b/apps/sim/lib/copilot/environment-context.ts index 4cd7c763e31..d939d60a69a 100644 --- a/apps/sim/lib/copilot/environment-context.ts +++ b/apps/sim/lib/copilot/environment-context.ts @@ -5,10 +5,7 @@ import { } from '@/lib/environment/utils' import { createResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -export type CopilotEnvironmentContext = Pick< - ExecutionContext, - 'decryptedEnvVars' | 'resolvedSecretTraceRegistry' -> +export type CopilotEnvironmentContext = Pick export async function createCopilotEnvironmentContext( userId: string, @@ -25,10 +22,6 @@ export async function createCopilotEnvironmentContext( }) return { - decryptedEnvVars: { - ...environment.personalDecrypted, - ...environment.workspaceDecrypted, - }, resolvedSecretTraceRegistry, } } diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index cda8d3097ea..56f01990d42 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -1768,7 +1768,7 @@ export const FunctionExecute: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -1900,7 +1900,14 @@ export const FunctionExecute: ToolCatalogEntry = { }, requiredPermission: 'write', requiresApproval: true, - capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], + capabilities: [ + 'file_input', + 'directory_input', + 'file_output', + 'table_input', + 'table_output', + 'secret_mount', + ], } export const GenerateApiKey: ToolCatalogEntry = { @@ -3766,7 +3773,7 @@ export const RunCode: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -3846,7 +3853,7 @@ export const RunCode: ToolCatalogEntry = { }, requiredPermission: 'write', requiresApproval: true, - capabilities: ['file_input', 'directory_input', 'table_input'], + capabilities: ['file_input', 'directory_input', 'table_input', 'secret_mount'], } export const RunFromBlock: ToolCatalogEntry = { diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 825443e2447..fd3280da1c7 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1464,7 +1464,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -3435,7 +3435,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index 1fd556a76bf..90d283890c6 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -28,7 +28,7 @@ export function createStreamingContext(overrides?: Partial): S errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { enabled: false, promptSurfaceAvailable: false, autoAllowed: new Set() }, ...overrides, } } diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index ebc2ce9f1be..9bad931cccb 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -32,7 +32,11 @@ function makeContext(): StreamingContext { wasAborted: false, errors: [], trace: new TraceCollector(), - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + promptSurfaceAvailable: false, + autoAllowed: new Set(), + }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 33979504936..237b01ba35c 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -109,7 +109,11 @@ function createStreamingContext(): StreamingContext { errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + promptSurfaceAvailable: false, + autoAllowed: new Set(), + }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index 5ebe3be2c4b..471904c16fe 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -377,7 +377,7 @@ export async function runStreamLoop( state: filePreviewAdapterState, }) - await prePersistClientExecutableToolCall(streamEvent, context, options) + await prePersistClientExecutableToolCall(streamEvent, context, options, execContext) try { await options.onEvent?.(streamEvent) diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index c5ad84af249..08b8e4e737b 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -15,16 +15,21 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApprov }) ) -const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall, markAsyncToolDelivered } = +const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ + upsertAsyncToolCall: vi.fn(), + markAsyncToolRunning: vi.fn(), + completeAsyncToolCall: vi.fn(), +})) + +const { waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion } = vi.hoisted(() => ({ - upsertAsyncToolCall: vi.fn(), - markAsyncToolRunning: vi.fn(), - completeAsyncToolCall: vi.fn(), - markAsyncToolDelivered: vi.fn(), + waitForClientToolCompletion: vi.fn(), + waitForToolCompletion: vi.fn(), + waitForWorkflowToolCompletion: vi.fn(), })) -const { waitForToolCompletion } = vi.hoisted(() => ({ - waitForToolCompletion: vi.fn(), +const { sealClientToolContext } = vi.hoisted(() => ({ + sealClientToolContext: vi.fn(), })) vi.mock('@/lib/copilot/tool-executor', () => ({ @@ -50,12 +55,17 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ releaseCompletedAsyncToolClaim: vi.fn(), upsertAsyncToolCall, markAsyncToolRunning, - markAsyncToolDelivered, completeAsyncToolCall, })) vi.mock('@/lib/copilot/request/tools/client', () => ({ + waitForClientToolCompletion, waitForToolCompletion, + waitForWorkflowToolCompletion, +})) + +vi.mock('@/lib/copilot/request/tools/client-completion-seal.server', () => ({ + sealClientToolContext, })) import { @@ -89,8 +99,12 @@ describe('sse-handlers tool lifecycle', () => { upsertAsyncToolCall.mockResolvedValue(null) markAsyncToolRunning.mockResolvedValue(null) completeAsyncToolCall.mockResolvedValue(null) - markAsyncToolDelivered.mockResolvedValue(null) waitForToolCompletion.mockResolvedValue(null) + waitForClientToolCompletion.mockResolvedValue(null) + waitForWorkflowToolCompletion.mockResolvedValue(null) + sealClientToolContext.mockResolvedValue({ + __sealedClientToolContextV1: 'sealed-context', + }) context = { chatId: undefined, messageId: 'msg-1', @@ -110,11 +124,16 @@ describe('sse-handlers tool lifecycle', () => { streamComplete: false, wasAborted: false, errors: [], - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + promptSurfaceAvailable: false, + autoAllowed: new Set(), + }, } execContext = { userId: 'user-1', workflowId: 'workflow-1', + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([]), } }) @@ -134,7 +153,9 @@ describe('sse-handlers tool lifecycle', () => { phase: MothershipStreamV1ToolPhase.call, }, } satisfies StreamEvent, - context + context, + {}, + execContext ) expect(upsertAsyncToolCall).toHaveBeenCalledWith({ @@ -142,14 +163,25 @@ describe('sse-handlers tool lifecycle', () => { toolCallId: 'browser-tool-1', toolName: 'browser_list_tabs', args: {}, + sealedContext: { __sealedClientToolContextV1: 'sealed-context' }, status: MothershipStreamV1AsyncToolRecordStatus.pending, }) + expect(sealClientToolContext).toHaveBeenCalledWith({ + toolCallId: 'browser-tool-1', + runId: 'run-1', + userId: 'user-1', + registry: execContext.resolvedSecretTraceRegistry, + }) }) it('persists a gated sim tool and stamps the frame so a reload can still answer it', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + promptSurfaceAvailable: true, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -177,13 +209,49 @@ describe('sse-handlers tool lifecycle', () => { expect(event.payload.status).toBe('awaiting_approval') }) + it('keeps one-call secret approval available when broad tool permissions are off', async () => { + context.runId = 'run-1' + context.toolPermissions = { + enabled: false, + promptSurfaceAvailable: true, + autoAllowed: new Set(), + } + + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'function-secret-1', + toolName: FunctionExecute.id, + arguments: { language: 'javascript', code: 'return {{API_KEY}}' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}) + + expect(event.payload.status).toBe('awaiting_approval') + expect(upsertAsyncToolCall).toHaveBeenCalledWith({ + runId: 'run-1', + toolCallId: 'function-secret-1', + toolName: FunctionExecute.id, + args: { language: 'javascript', code: 'return {{API_KEY}}' }, + status: MothershipStreamV1AsyncToolRecordStatus.pending, + }) + }) + it('clears a Go-stamped approval frame when the gate is off', async () => { // Go stamps integration calls regardless of Sim's feature flag. Forwarding // that stamp with nothing gating behind it would draw a card whose buttons // answer into a disabled endpoint. toolRequiresApproval.mockReturnValue(false) context.runId = 'run-1' - context.toolPermissions = { enabled: false, autoAllowed: new Set() } + context.toolPermissions = { + enabled: false, + promptSurfaceAvailable: true, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -207,7 +275,11 @@ describe('sse-handlers tool lifecycle', () => { it('clears a Go-stamped approval frame on an internal tool', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + promptSurfaceAvailable: true, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -233,7 +305,11 @@ describe('sse-handlers tool lifecycle', () => { it('leaves an already always-allowed tool ungated', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set(['deploy_api']) } + context.toolPermissions = { + enabled: true, + promptSurfaceAvailable: true, + autoAllowed: new Set(['deploy_api']), + } const event = { type: MothershipStreamV1EventType.tool, @@ -442,12 +518,14 @@ describe('sse-handlers tool lifecycle', () => { ]) registry.recordResolved('SECRET', 'secret-value') execContext.resolvedSecretTraceRegistry = registry + execContext.chatId = 'chat-1' executeTool.mockResolvedValueOnce({ success: true, output: { result: 'secret-value', stdout: 'prefix secret-value', }, + resources: [{ type: 'file', id: 'file-1', title: 'secret-value.txt' }], }) const onEvent = vi.fn() @@ -489,12 +567,23 @@ describe('sse-handlers tool lifecycle', () => { }) ) expect(context.toolCalls.get('tool-function')?.result?.output).toEqual(safeOutput) + expect(onEvent).toHaveBeenCalledWith({ + type: MothershipStreamV1EventType.resource, + payload: { + op: MothershipStreamV1ResourceOp.upsert, + resource: { + type: 'file', + id: 'file-1', + title: '{{SECRET}}.txt', + }, + }, + }) expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('secret-value') expect(JSON.stringify(onEvent.mock.calls)).not.toContain('secret-value') }) - it('marks background client workflow tools delivered after synthetic result emission', async () => { - waitForToolCompletion.mockResolvedValueOnce({ + it('emits a structural result for a detached background workflow tool', async () => { + waitForWorkflowToolCompletion.mockResolvedValueOnce({ status: 'background', data: { detached: true }, }) @@ -520,7 +609,13 @@ describe('sse-handlers tool lifecycle', () => { await sleep(0) await Promise.allSettled(context.pendingToolPromises.values()) - expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-background') + expect(waitForWorkflowToolCompletion).toHaveBeenCalledWith({ + toolCallId: 'tool-background', + workflowId: 'workflow-1', + timeoutMs: 1000, + abortSignal: undefined, + registry: execContext.resolvedSecretTraceRegistry, + }) expect(onEvent).toHaveBeenCalledWith( expect.objectContaining({ type: MothershipStreamV1EventType.tool, @@ -539,10 +634,12 @@ describe('sse-handlers tool lifecycle', () => { }) it('waits for the desktop client when a static VFS read is explicitly user-local', async () => { - waitForToolCompletion.mockResolvedValueOnce({ + waitForClientToolCompletion.mockResolvedValueOnce({ status: 'success', - data: { content: 'hello', totalLines: 1 }, + message: 'Read {{SECRET}}', + data: { content: '{{SECRET}}', totalLines: 1 }, }) + const onEvent = vi.fn() await sseHandlers.tool( { @@ -558,12 +655,32 @@ describe('sse-handlers tool lifecycle', () => { } satisfies StreamEvent, context, execContext, - { onEvent: vi.fn(), interactive: true, timeout: 1000 } + { onEvent, interactive: true, timeout: 1000 } ) await Promise.allSettled(context.pendingToolPromises.values()) - expect(waitForToolCompletion).toHaveBeenCalledWith('tool-user-local-read', 1000, undefined) + expect(waitForClientToolCompletion).toHaveBeenCalledWith({ + toolCallId: 'tool-user-local-read', + runId: context.runId, + userId: 'user-1', + timeoutMs: 1000, + abortSignal: undefined, + registry: execContext.resolvedSecretTraceRegistry, + }) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.tool, + payload: expect.objectContaining({ + phase: MothershipStreamV1ToolPhase.result, + output: { content: '{{SECRET}}', totalLines: 1 }, + }), + }) + ) + expect(JSON.stringify(context.toolCalls.get('tool-user-local-read'))).not.toContain( + 'resolved-secret' + ) + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('resolved-secret') expect(executeTool).not.toHaveBeenCalled() }) @@ -707,6 +824,53 @@ describe('sse-handlers tool lifecycle', () => { expect(context.toolCalls.has('glob-generating')).toBe(false) }) + it('executes finalized main-tool arguments instead of a generating snapshot', async () => { + executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'function-finalized-args', + toolName: FunctionExecute.id, + arguments: { language: 'javascript', code: 'return {{STALE_SECRET}}' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'function-finalized-args', + toolName: FunctionExecute.id, + arguments: { language: 'javascript', code: 'return 1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sleep(0) + + expect(executeTool).toHaveBeenCalledWith( + FunctionExecute.id, + { language: 'javascript', code: 'return 1' }, + expect.any(Object) + ) + }) + it('updates stored params when a subagent generating event is followed by the final tool call', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) context.toolCalls.set('parent-1', { @@ -727,6 +891,7 @@ describe('sse-handlers tool lifecycle', () => { mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.call, status: 'generating', + arguments: { name: 'Stale Workflow' }, }, } satisfies StreamEvent, context, @@ -1097,10 +1262,22 @@ describe('sse-handlers tool lifecycle', () => { const firstPromise = context.pendingToolPromises.get('tool-inflight') expect(firstPromise).toBeDefined() - await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false }) + await sseHandlers.tool( + { + ...event, + payload: { + ...event.payload, + arguments: { workflowId: 'workflow-2' }, + }, + } as StreamEvent, + context, + execContext, + { interactive: false } + ) expect(executeTool).toHaveBeenCalledTimes(1) expect(context.pendingToolPromises.get('tool-inflight')).toBe(firstPromise) + expect(context.toolCalls.get('tool-inflight')?.params).toEqual({ workflowId: 'workflow-1' }) resolveTool?.({ success: true, output: { ok: true } }) await sleep(0) diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 880edae7433..afa1e03db00 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -2,11 +2,8 @@ import { isBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { isTerminalToolName } from '@sim/terminal-protocol' import { getErrorMessage, toError } from '@sim/utils/errors' -import { - ASYNC_TOOL_CONFIRMATION_STATUS, - type AsyncCompletionSignal, -} from '@/lib/copilot/async-runs/lifecycle' -import { markAsyncToolDelivered, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' +import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' +import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' import { MothershipStreamV1AsyncToolRecordStatus, @@ -26,7 +23,12 @@ import { } from '@/lib/copilot/request/session' import { markToolResultSeen, wasToolResultSeen } from '@/lib/copilot/request/sse-utils' import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' -import { executeToolAndReport, waitForToolCompletion } from '@/lib/copilot/request/tools/executor' +import { + waitForClientToolCompletion, + waitForWorkflowToolCompletion, +} from '@/lib/copilot/request/tools/client' +import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server' +import { executeToolAndReport } from '@/lib/copilot/request/tools/executor' import { runGatedToolExecution, TOOL_AWAITING_APPROVAL_STATUS, @@ -44,7 +46,7 @@ import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' -import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' +import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' import { getBlockByToolName } from '@/blocks/registry' import type { ToolScope } from './types' import { @@ -169,7 +171,8 @@ function rebindResolvedIntegrationCall( export async function prePersistClientExecutableToolCall( event: StreamEvent, context: StreamingContext, - options?: OrchestratorOptions + options?: OrchestratorOptions, + execContext?: ExecutionContext ): Promise { if (event.type !== 'tool') return if (!isToolCallStreamEvent(event)) return @@ -221,11 +224,30 @@ export async function prePersistClientExecutableToolCall( if (!context.runId) return + let sealedContext: Awaited> | undefined + if (execContext?.resolvedSecretTraceRegistry) { + try { + sealedContext = await sealClientToolContext({ + toolCallId: data.toolCallId, + runId: context.runId, + userId: execContext.userId, + registry: execContext.resolvedSecretTraceRegistry, + }) + } catch (error) { + execContext.resolvedSecretTraceRegistry.markIncomplete() + logger.warn('Failed to seal client tool provenance', { + toolCallId: data.toolCallId, + error: getErrorMessage(error), + }) + } + } + await upsertAsyncToolCall({ runId: context.runId, toolCallId: data.toolCallId, toolName: data.toolName, args: data.arguments, + sealedContext, // Browser and terminal actions cross a second, native authorization // boundary. Leave those rows pending until Electron atomically claims // them — the authorize endpoint only hands over a pending call, so a row @@ -399,11 +421,20 @@ async function handleCallPhase( if (isPartial && shouldDelayVfsPlaceholder(toolName, args)) return + if ( + existing && + (context.pendingToolPromises.has(toolCallId) || + existing.status === 'awaiting_approval' || + existing.status === 'executing') + ) { + applyToolDisplay(existing) + return + } + if (isSubagent) { if (wasToolResultSeen(toolCallId) || existing?.endTime) { if (!rebindResolvedIntegrationCall(existing, toolName, args)) { - if (existing && !existing.name && toolName) existing.name = toolName - if (existing && !existing.params && args) existing.params = args + if (existing) updateToolCallFromFrame(existing, toolName, args, !isPartial) } applyToolDisplay(existing) return @@ -414,8 +445,7 @@ async function handleCallPhase( (existing && existing.status !== 'pending' && existing.status !== 'executing') ) { if (!rebindResolvedIntegrationCall(existing, toolName, args)) { - if (!existing.name && toolName) existing.name = toolName - if (!existing.params && args) existing.params = args + updateToolCallFromFrame(existing, toolName, args, !isPartial) } applyToolDisplay(existing) return @@ -430,10 +460,11 @@ async function handleCallPhase( args, parentToolCallId!, ui, - spanIdentity + spanIdentity, + !isPartial ) } else { - registerMainToolCall(context, toolCallId, toolName, args, existing, ui) + registerMainToolCall(context, toolCallId, toolName, args, existing, ui, !isPartial) } if (isPartial) return @@ -507,6 +538,16 @@ function removeToolCallContentBlock(context: StreamingContext, toolCallId: strin } } +function updateToolCallFromFrame( + toolCall: ToolCallState, + toolName: string, + args: Record | undefined, + finalized: boolean +): void { + if (!toolCall.name && toolName) toolCall.name = toolName + if (finalized || args !== undefined) toolCall.params = args +} + function registerSubagentToolCall( context: StreamingContext, toolCallId: string, @@ -514,7 +555,8 @@ function registerSubagentToolCall( args: Record | undefined, parentToolCallId: string, ui: { title?: string; phaseLabel?: string; hidden?: boolean }, - spanIdentity: { spanId?: string; parentSpanId?: string } + spanIdentity: { spanId?: string; parentSpanId?: string }, + finalized: boolean ): void { if (!context.subAgentToolCalls[parentToolCallId]) { context.subAgentToolCalls[parentToolCallId] = [] @@ -523,8 +565,7 @@ function registerSubagentToolCall( let toolCall = context.toolCalls.get(toolCallId) if (toolCall) { if (!rebindResolvedIntegrationCall(toolCall, toolName, args)) { - if (!toolCall.name && toolName) toolCall.name = toolName - if (args && !toolCall.params) toolCall.params = args + updateToolCallFromFrame(toolCall, toolName, args, finalized) } applyToolDisplay(toolCall) if (hideFromUi) removeToolCallContentBlock(context, toolCallId) @@ -554,8 +595,7 @@ function registerSubagentToolCall( const existingSubagentToolCall = subagentToolCalls.find((tc) => tc.id === toolCallId) if (existingSubagentToolCall) { if (!rebindResolvedIntegrationCall(existingSubagentToolCall, toolName, args)) { - if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName - if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args + updateToolCallFromFrame(existingSubagentToolCall, toolName, args, finalized) } applyToolDisplay(existingSubagentToolCall) } else { @@ -569,12 +609,13 @@ function registerMainToolCall( toolName: string, args: Record | undefined, existing: ToolCallState | undefined, - ui: { title?: string; phaseLabel?: string; hidden?: boolean } + ui: { title?: string; phaseLabel?: string; hidden?: boolean }, + finalized: boolean ): void { const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true if (existing) { - if (!rebindResolvedIntegrationCall(existing, toolName, args) && args && !existing.params) { - existing.params = args + if (!rebindResolvedIntegrationCall(existing, toolName, args)) { + updateToolCallFromFrame(existing, toolName, args, finalized) } applyToolDisplay(existing) if (hideFromUi) { @@ -675,7 +716,7 @@ async function dispatchToolExecution( context, options, startExecution, - !hiddenInUi + !hiddenInUi && context.toolPermissions.promptSurfaceAvailable ) ) return @@ -699,25 +740,27 @@ async function dispatchToolExecution( ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), }, async (span) => { - const completion = await waitForToolCompletion( - toolCallId, - options.timeout || STREAM_TIMEOUT_MS, - options.abortSignal - ) + const completion = isWorkflowToolName(toolName) + ? await waitForWorkflowToolCompletion({ + toolCallId, + workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId), + timeoutMs: options.timeout || STREAM_TIMEOUT_MS, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + }) + : await waitForClientToolCompletion({ + toolCallId, + runId: context.runId, + userId: execContext.userId, + timeoutMs: options.timeout || STREAM_TIMEOUT_MS, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + }) span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined) if (completion) { span.setAttribute(TraceAttr.ToolOutcome, completion.status) } handleClientCompletion(toolCall, toolCallId, completion) - if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { - await markAsyncToolDelivered(toolCallId).catch((err) => { - logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, { - toolCallId, - toolName, - error: toError(err).message, - }) - }) - } await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return ( completion ?? { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index cdfab8dbaf4..bdbbef344ea 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -146,7 +146,7 @@ describe('runCopilotLifecycle', () => { mockGetAutoAllowedTools.mockResolvedValue(new Set()) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) - mockPrepareCopilotEnvironmentContext.mockResolvedValue({ decryptedEnvVars: {} }) + mockPrepareCopilotEnvironmentContext.mockResolvedValue({}) }) it('threads trace provenance through server execution context only', async () => { @@ -155,7 +155,6 @@ describe('runCopilotLifecycle', () => { userId: 'user-1', workflowId: '', workspaceId: 'ws-1', - decryptedEnvVars: {}, } let capturedExecutionContext: ExecutionContext | undefined let capturedRequestBody = '' @@ -203,12 +202,11 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, }, } ) - it('stays entirely inert while the flag is off', async () => { + it('keeps only the one-call prompt surface available while the broad flag is off', async () => { let captured: StreamingContext | undefined mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { captured = context @@ -217,6 +215,7 @@ describe('runCopilotLifecycle', () => { await runMothershipTurn() expect(captured?.toolPermissions.enabled).toBe(false) + expect(captured?.toolPermissions.promptSurfaceAvailable).toBe(true) // Never even reads the preference tables when disabled. expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() }) @@ -232,6 +231,7 @@ describe('runCopilotLifecycle', () => { await runMothershipTurn() expect(captured?.toolPermissions.enabled).toBe(true) + expect(captured?.toolPermissions.promptSurfaceAvailable).toBe(true) expect(captured?.toolPermissions.autoAllowed.has('terminal_run')).toBe(true) expect(mockGetAutoAllowedTools).toHaveBeenCalledWith('user-1', 'chat-1') }) @@ -257,12 +257,12 @@ describe('runCopilotLifecycle', () => { workflowId: 'wf-1', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, }, } ) expect(captured?.toolPermissions.enabled).toBe(false) + expect(captured?.toolPermissions.promptSurfaceAvailable).toBe(false) expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() }) }) @@ -277,7 +277,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -349,7 +348,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -402,7 +400,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -444,7 +441,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -480,7 +476,7 @@ describe('runCopilotLifecycle', () => { ) }) - it('propagates payload userPermission into the generated execution context', async () => { + it('does not trust payload userPermission when building the execution context', async () => { let capturedExecContext: ExecutionContext | undefined mockRunStreamLoop.mockImplementationOnce( async ( @@ -507,9 +503,35 @@ describe('runCopilotLifecycle', () => { userId: 'user-1', workspaceId: 'ws-1', chatId: 'chat-1', - userPermission: 'write', }) ) + expect(capturedExecContext).not.toHaveProperty('userPermission') + }) + + it('uses only the trusted lifecycle userPermission option', async () => { + let capturedExecContext: ExecutionContext | undefined + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + _context: StreamingContext, + execContext: ExecutionContext + ): Promise => { + capturedExecContext = execContext + } + ) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1', userPermission: 'admin' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + userPermission: 'read', + } + ) + + expect(capturedExecContext?.userPermission).toBe('read') }) it('uses one server billing identity and immutable attribution on initial and resume legs', async () => { @@ -710,7 +732,6 @@ describe('runCopilotLifecycle', () => { workflowId: 'workflow-1', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -769,7 +790,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // 1) Initial stream pauses on an async tool checkpoint with a resolved @@ -851,7 +871,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // Initial leg pauses on a resolved async tool checkpoint → enters resume. @@ -921,7 +940,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -984,7 +1002,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -1046,7 +1063,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -1086,7 +1102,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // Mirror the real helper: settle the tool call into a terminal error diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 5d61c8f6b3d..a8939ba7a24 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -1,5 +1,6 @@ import type { Context } from '@opentelemetry/api' import { createLogger } from '@sim/logger' +import type { PermissionType } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' @@ -57,6 +58,7 @@ import type { StreamEvent, StreamingContext, } from '@/lib/copilot/request/types' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { env } from '@/lib/core/config/env' @@ -101,29 +103,31 @@ export interface CopilotLifecycleOptions extends OrchestratorOptions { billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry environmentContext?: CopilotEnvironmentContext + userPermission?: PermissionType + secretMountPolicy?: SecretMountPolicy + secretActorUserId?: string | null } /** * Seed the per-request tool permission state. * - * This is the feature's single on-switch: everything downstream — stamping the - * wire frame, holding the tool, drawing the card, persisting a decision — keys - * off `enabled`, so a disabled request behaves exactly as it did before the - * feature existed and never touches the preference tables. - * - * Beyond the flag, gating is limited to interactive mothership chats: that is - * the only surface with a UI that can answer a prompt, so enabling it anywhere - * else would hang the turn until the orchestration timeout with nothing to click. + * The broad feature flag controls ordinary tool approvals and saved auto-allow + * preferences. `promptSurfaceAvailable` separately records whether this run has + * a visible interactive row that can collect the mandatory one-call approval + * for a secret mount. */ async function resolveToolPermissions( options: CopilotLifecycleOptions ): Promise { - const enabled = - isCopilotToolPermissionsEnabled && - options.interactive !== false && - (options.goRoute ?? '').startsWith('/api/mothership') - if (!enabled) return { enabled: false, autoAllowed: new Set() } - return { enabled: true, autoAllowed: await getAutoAllowedTools(options.userId, options.chatId) } + const promptSurfaceAvailable = + options.interactive !== false && (options.goRoute ?? '').startsWith('/api/mothership') + const enabled = isCopilotToolPermissionsEnabled && promptSurfaceAvailable + if (!enabled) return { enabled: false, promptSurfaceAvailable, autoAllowed: new Set() } + return { + enabled: true, + promptSurfaceAvailable, + autoAllowed: await getAutoAllowedTools(options.userId, options.chatId), + } } export async function runCopilotLifecycle( @@ -167,9 +171,14 @@ export async function runCopilotLifecycle( abortSignal: options.abortSignal, billingAttribution: options.billingAttribution ?? options.executionContext.billingAttribution, + ...(options.userPermission ? { userPermission: options.userPermission } : {}), ...(options.resolvedSecretTraceRegistry ? { resolvedSecretTraceRegistry: options.resolvedSecretTraceRegistry } : {}), + ...(options.secretMountPolicy ? { secretMountPolicy: options.secretMountPolicy } : {}), + ...(options.secretActorUserId !== undefined + ? { secretActorUserId: options.secretActorUserId } + : {}), }, } : {}), @@ -188,6 +197,9 @@ export async function runCopilotLifecycle( billingAttribution: lifecycleOptions.billingAttribution, resolvedSecretTraceRegistry: lifecycleOptions.resolvedSecretTraceRegistry, environmentContext: lifecycleOptions.environmentContext, + userPermission: lifecycleOptions.userPermission, + secretMountPolicy: lifecycleOptions.secretMountPolicy, + secretActorUserId: lifecycleOptions.secretActorUserId, })) const shouldUseHostedBillingProtocol = isHosted && isCopilotBillingAttributionV1Enabled if ( @@ -1006,6 +1018,9 @@ async function buildExecutionContext( billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry environmentContext?: CopilotEnvironmentContext + userPermission?: PermissionType + secretMountPolicy?: SecretMountPolicy + secretActorUserId?: string | null } ): Promise { const { @@ -1019,12 +1034,13 @@ async function buildExecutionContext( billingAttribution, resolvedSecretTraceRegistry, environmentContext, + userPermission, + secretMountPolicy, + secretActorUserId, } = params const userTimezone = typeof requestPayload?.userTimezone === 'string' ? requestPayload.userTimezone : undefined const requestMode = typeof requestPayload?.mode === 'string' ? requestPayload.mode : undefined - const userPermission = - typeof requestPayload?.userPermission === 'string' ? requestPayload.userPermission : undefined let execContext: ExecutionContext if (workflowId) { @@ -1059,6 +1075,8 @@ async function buildExecutionContext( if (resolvedSecretTraceRegistry) { execContext.resolvedSecretTraceRegistry = resolvedSecretTraceRegistry } + if (secretMountPolicy) execContext.secretMountPolicy = secretMountPolicy + if (secretActorUserId !== undefined) execContext.secretActorUserId = secretActorUserId return execContext } diff --git a/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts b/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts new file mode 100644 index 00000000000..1204c5f7b92 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts @@ -0,0 +1,136 @@ +import { generateId } from '@sim/utils/id' +import { isPlainRecord } from '@sim/utils/object' +import type { AsyncCompletionData } from '@/lib/copilot/async-runs/lifecycle' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { + isResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' + +export const SEALED_CLIENT_TOOL_COMPLETION_FIELD = '__sealedClientToolCompletionV1' +export const SEALED_CLIENT_TOOL_CONTEXT_FIELD = '__sealedClientToolContextV1' + +interface ClientToolBinding { + toolCallId: string + runId: string + userId: string +} + +interface ClientToolCompletionContent extends ClientToolBinding { + message?: string + data?: AsyncCompletionData +} + +interface ClientToolContext extends ClientToolBinding { + registryInstanceId: string + provenance: ResolvedSecretTraceProvenanceV1 +} + +interface SealClientToolContextInput extends ClientToolBinding { + registry: ResolvedSecretTraceRegistry +} + +type ClientCompletionSealGlobal = typeof globalThis & { + _clientToolRegistryInstanceIds?: WeakMap +} + +const sealGlobal = globalThis as ClientCompletionSealGlobal +sealGlobal._clientToolRegistryInstanceIds ??= new WeakMap() +const registryInstanceIds = sealGlobal._clientToolRegistryInstanceIds + +function getRegistryInstanceId(registry: ResolvedSecretTraceRegistry): string { + const existing = registryInstanceIds.get(registry) + if (existing) return existing + + const created = generateId() + registryInstanceIds.set(registry, created) + return created +} + +function bindingMatches(value: Record, expected: ClientToolBinding): boolean { + return ( + value.toolCallId === expected.toolCallId && + value.runId === expected.runId && + value.userId === expected.userId + ) +} + +export async function sealClientToolCompletion( + content: ClientToolCompletionContent +): Promise> { + const { encrypted } = await encryptSecret(JSON.stringify(content)) + return { [SEALED_CLIENT_TOOL_COMPLETION_FIELD]: encrypted } +} + +export async function unsealClientToolCompletion( + value: unknown, + expected: ClientToolBinding +): Promise { + if (!isPlainRecord(value)) return null + const sealed = value[SEALED_CLIENT_TOOL_COMPLETION_FIELD] + if (typeof sealed !== 'string' || sealed.length === 0) return null + + try { + const { decrypted } = await decryptSecret(sealed) + const content: unknown = JSON.parse(decrypted) + if (!isPlainRecord(content)) return null + if (!bindingMatches(content, expected)) return null + if (content.message !== undefined && typeof content.message !== 'string') return null + return { + ...expected, + ...(content.message !== undefined ? { message: content.message } : {}), + ...(Object.hasOwn(content, 'data') ? { data: content.data } : {}), + } + } catch { + return null + } +} + +export async function sealClientToolContext( + input: SealClientToolContextInput +): Promise> { + const { registry, ...binding } = input + const context: ClientToolContext = { + ...binding, + registryInstanceId: getRegistryInstanceId(registry), + provenance: registry.exportProvenance(), + } + const { encrypted } = await encryptSecret(JSON.stringify(context)) + return { [SEALED_CLIENT_TOOL_CONTEXT_FIELD]: encrypted } +} + +export function retainSealedClientToolContext( + value: unknown +): Partial> { + if (!isPlainRecord(value)) return {} + const sealed = value[SEALED_CLIENT_TOOL_CONTEXT_FIELD] + return typeof sealed === 'string' && sealed.length > 0 + ? { [SEALED_CLIENT_TOOL_CONTEXT_FIELD]: sealed } + : {} +} + +export async function unsealClientToolContext( + value: unknown, + expected: ClientToolBinding, + registry: ResolvedSecretTraceRegistry +): Promise { + if (!isPlainRecord(value)) return null + const sealed = value[SEALED_CLIENT_TOOL_CONTEXT_FIELD] + if (typeof sealed !== 'string' || sealed.length === 0) return null + + try { + const { decrypted } = await decryptSecret(sealed) + const context: unknown = JSON.parse(decrypted) + if (!isPlainRecord(context) || !bindingMatches(context, expected)) return null + if (context.registryInstanceId !== getRegistryInstanceId(registry)) return null + if (!isResolvedSecretTraceProvenanceV1(context.provenance)) return null + return { + ...expected, + registryInstanceId: context.registryInstanceId, + provenance: context.provenance, + } + } catch { + return null + } +} diff --git a/apps/sim/lib/copilot/request/tools/client.test.ts b/apps/sim/lib/copilot/request/tools/client.test.ts new file mode 100644 index 00000000000..e993daef3ea --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/client.test.ts @@ -0,0 +1,567 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + encryptSecret, + decryptSecret, + waitForToolConfirmation, + replaceTerminalAsyncToolCallResult, + getTrustedWorkflowToolExecution, +} = vi.hoisted(() => ({ + encryptSecret: vi.fn(), + decryptSecret: vi.fn(), + waitForToolConfirmation: vi.fn(), + replaceTerminalAsyncToolCallResult: vi.fn(), + getTrustedWorkflowToolExecution: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret, + decryptSecret, +})) + +vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ + waitForToolConfirmation, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + replaceTerminalAsyncToolCallResult, +})) + +vi.mock('@/lib/workflows/executor/execution-state', () => ({ + getTrustedWorkflowToolExecution, +})) + +import { + waitForClientToolCompletion, + waitForWorkflowToolCompletion, +} from '@/lib/copilot/request/tools/client' +import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const TRACE_SCOPE = { userId: 'user-1', workspaceId: 'workspace-1' } + +function createParentRegistry(): ResolvedSecretTraceRegistry { + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'PARENT_SECRET', + plaintext: 'parent-secret-value', + encryptedValue: 'encrypted-parent-secret', + }, + ], + TRACE_SCOPE + ) + registry.recordResolved('PARENT_SECRET', 'parent-secret-value') + return registry +} + +function createClientRegistry(): ResolvedSecretTraceRegistry { + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'SECRET', + plaintext: 'resolved-secret', + encryptedValue: 'encrypted-secret', + }, + ], + TRACE_SCOPE + ) + registry.recordResolved('SECRET', 'resolved-secret') + return registry +} + +function trustedExecution(executionId: string) { + return { + executionId, + workflowId: 'workflow-1', + status: 'completed' as const, + finalOutput: { value: `child read parent-secret-value from ${executionId}` }, + blockLogs: [], + provenance: { + version: 1 as const, + complete: true, + entries: [], + scope: TRACE_SCOPE, + }, + } +} + +describe('workflow client tool completion', () => { + beforeEach(() => { + vi.clearAllMocks() + decryptSecret.mockResolvedValue({ decrypted: 'child-secret-value' }) + replaceTerminalAsyncToolCallResult.mockResolvedValue({ status: 'completed' }) + }) + + it('projects a parent secret laundered through a child workflow before every live sink', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(trustedExecution('execution-1')) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(getTrustedWorkflowToolExecution).toHaveBeenCalledWith( + 'execution-1', + 'workflow-1', + 'tool-1' + ) + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + output: { value: 'child read {{PARENT_SECRET}} from execution-1' }, + logs: [], + }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: completion?.data, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('parent-secret-value') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'parent-secret-value' + ) + }) + + it('fails closed when the bound execution or complete provenance is unavailable', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1', output: 'untrusted' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(null) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + expect(JSON.stringify(completion)).not.toContain('untrusted') + }) + + it('imports and projects a secret activated only inside the child workflow', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + finalOutput: { value: 'child-secret-value' }, + blockLogs: [], + provenance: { + version: 1, + complete: true, + entries: [{ name: 'CHILD_SECRET', encryptedValue: 'encrypted-child-secret' }], + scope: TRACE_SCOPE, + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(decryptSecret).toHaveBeenCalledWith('encrypted-child-secret') + expect(completion?.data).toEqual({ + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + output: { value: '{{CHILD_SECRET}}' }, + logs: [], + }) + expect(JSON.stringify(completion)).not.toContain('child-secret-value') + }) + + it('corrects the client terminal status from the bound execution log', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'failed', + error: 'trusted failure', + blockLogs: [], + provenance: { version: 1, complete: true, entries: [], scope: TRACE_SCOPE }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toMatchObject({ + status: 'error', + message: 'trusted failure', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + error: 'trusted failure', + }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'failed', + result: completion?.data, + error: 'trusted failure', + }) + }) + + it('treats background completion as structural and incomplete', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'background', + data: { + workflowId: 'workflow-1', + executionId: 'execution-1', + output: 'untrusted-background-output', + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'background', + message: 'Workflow execution is continuing in the background.', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + expect(registry.isComplete()).toBe(false) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('fails structurally when trusted child provenance cannot be imported', async () => { + const registry = createParentRegistry() + vi.spyOn(registry, 'importCrossingProvenance').mockRejectedValueOnce( + new Error('decryption unavailable') + ) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(trustedExecution('execution-1')) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(JSON.stringify(completion)).not.toContain('parent-secret-value') + }) + + it('keeps parallel workflow results safe while sibling provenance is unresolved', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockImplementation((toolCallId: string) => + Promise.resolve({ + status: 'success', + data: { + workflowId: 'workflow-1', + executionId: toolCallId === 'tool-1' ? 'execution-1' : 'execution-2', + }, + }) + ) + + const resolvers = new Map) => void>() + getTrustedWorkflowToolExecution.mockImplementation( + (executionId: string) => + new Promise((resolve) => { + resolvers.set(executionId, resolve) + }) + ) + + const firstPromise = waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + const secondPromise = waitForWorkflowToolCompletion({ + toolCallId: 'tool-2', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + await vi.waitFor(() => expect(resolvers.size).toBe(2)) + resolvers.get('execution-1')?.(trustedExecution('execution-1')) + const first = await firstPromise + + expect(first).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + + resolvers.get('execution-2')?.(trustedExecution('execution-2')) + const second = await secondPromise + + expect(second).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-2', + output: { value: 'child read {{PARENT_SECRET}} from execution-2' }, + logs: [], + }, + }) + expect(JSON.stringify([first, second])).not.toContain('parent-secret-value') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'parent-secret-value' + ) + }) +}) + +describe('generic client tool completion', () => { + beforeEach(() => { + vi.clearAllMocks() + encryptSecret.mockImplementation(async (plaintext: string) => ({ + encrypted: plaintext, + iv: 'iv', + })) + decryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: encrypted === 'encrypted-secret' ? 'resolved-secret' : encrypted, + })) + replaceTerminalAsyncToolCallResult.mockResolvedValue({ status: 'completed' }) + }) + + it('unseals exact-bound content and provenance, then persists only the projected result', async () => { + const registry = createClientRegistry() + const sealedContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + message: 'Read resolved-secret', + data: { content: 'prefix-resolved-secret-suffix' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Read {{SECRET}}', + data: { content: 'prefix-{{SECRET}}-suffix' }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: { content: 'prefix-{{SECRET}}-suffix' }, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('resolved-secret') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'resolved-secret' + ) + }) + + it('fails structurally without an execution registry', async () => { + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: 'sealed-completion', + __sealedClientToolContextV1: 'sealed-context', + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(decryptSecret).not.toHaveBeenCalled() + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + }) + + it.each([ + ['wrong tool', { toolCallId: 'other-tool', runId: 'run-1', userId: 'user-1' }], + ['wrong run', { toolCallId: 'tool-1', runId: 'other-run', userId: 'user-1' }], + ['wrong user', { toolCallId: 'tool-1', runId: 'run-1', userId: 'other-user' }], + ])('fails structurally for a completion bound to the %s', async (_label, sealedBinding) => { + const registry = createClientRegistry() + const sealedContext = await sealClientToolContext({ ...sealedBinding, registry }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + ...sealedBinding, + data: { content: 'untrusted-secret' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('untrusted-secret') + }) + + it('fails structurally when a restarted execution uses a new registry instance', async () => { + const sourceRegistry = createClientRegistry() + const resumedRegistry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + const sealedContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry: sourceRegistry, + }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry: resumedRegistry, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(resumedRegistry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('resolved-secret') + }) + + it('fails structurally for a legacy raw confirmation without sealed provenance', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'error', + message: 'raw error secret', + data: { content: 'raw result secret' }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ status: 'error', message: 'Tool result omitted' }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'failed', + result: null, + error: 'Tool result omitted', + }) + expect(JSON.stringify(completion)).not.toContain('raw') + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/client.ts b/apps/sim/lib/copilot/request/tools/client.ts index 43c42de6a8c..4a3f5b283a0 100644 --- a/apps/sim/lib/copilot/request/tools/client.ts +++ b/apps/sim/lib/copilot/request/tools/client.ts @@ -1,10 +1,28 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isPlainRecord } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncTerminalCompletionSnapshot, isAsyncTerminalConfirmationStatus, } from '@/lib/copilot/async-runs/lifecycle' +import { replaceTerminalAsyncToolCallResult } from '@/lib/copilot/async-runs/repository' import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' import { waitForToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' +import { + unsealClientToolCompletion, + unsealClientToolContext, +} from '@/lib/copilot/request/tools/client-completion-seal.server' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +import { + createStructuralWorkflowToolCompletionData, + getWorkflowToolCompletionMessage, + getWorkflowToolConfirmationStatus, +} from '@/lib/copilot/tools/workflow-tools' +import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const logger = createLogger('CopilotClientToolWaiter') /** * Wait for a client-executable workflow tool to report back. @@ -31,3 +49,296 @@ export async function waitForToolCompletion( } return null } + +interface WaitForClientToolCompletionOptions { + toolCallId: string + runId?: string + userId: string + timeoutMs: number + abortSignal?: AbortSignal + registry?: ResolvedSecretTraceRegistry +} + +function getGenericCompletionMessage(status: AsyncTerminalCompletionSnapshot['status']): string { + if (status === MothershipStreamV1ToolOutcome.success) return 'Tool completed' + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) return 'Tool is running in background' + if (status === MothershipStreamV1ToolOutcome.cancelled) return 'Tool cancelled' + return 'Tool failed' +} + +/** + * Restores a generic browser/terminal result from its sealed transport envelope, + * projects active Secrets values, then replaces the durable row before delivery. + */ +export async function waitForClientToolCompletion({ + toolCallId, + runId, + userId, + timeoutMs, + abortSignal, + registry, +}: WaitForClientToolCompletionOptions): Promise { + const completion = await waitForToolCompletion(toolCallId, timeoutMs, abortSignal) + if (!completion) return null + + const genericMessage = getGenericCompletionMessage(completion.status) + const binding = runId ? { toolCallId, runId, userId } : undefined + const registryWasComplete = registry?.isComplete() === true + const finishPendingActivation = registry?.beginPendingActivation() + let content: Awaited> = null + try { + const [sealedContent, sealedContext] = + binding && registry && registryWasComplete + ? await Promise.all([ + unsealClientToolCompletion(completion.data, binding), + unsealClientToolContext(completion.data, binding, registry), + ]) + : [null, null] + if (!registry || !registryWasComplete || !sealedContent || !sealedContext) { + registry?.markIncomplete() + } else { + const imported = await registry.importProvenance(sealedContext.provenance, { trusted: true }) + if (!imported || !sealedContext.provenance.complete) { + registry.markIncomplete() + } else { + content = sealedContent + } + } + } catch { + registry?.markIncomplete() + } finally { + finishPendingActivation?.() + } + if (!registry?.isComplete()) content = null + + const rawOutput: Record = { + ...(content?.message !== undefined ? { message: content.message } : {}), + ...(content && Object.hasOwn(content, 'data') ? { data: content.data } : {}), + } + const succeeded = completion.status === MothershipStreamV1ToolOutcome.success + const projected = projectToolResultForCopilot( + { + success: succeeded, + output: rawOutput, + ...(!succeeded ? { error: content?.message ?? genericMessage } : {}), + }, + registry + ) + const projectedOutput = isPlainRecord(projected.output) ? projected.output : undefined + const message = + typeof projectedOutput?.message === 'string' + ? projectedOutput.message + : !succeeded && projected.error + ? projected.error + : genericMessage + const data = + projectedOutput && Object.hasOwn(projectedOutput, 'data') ? projectedOutput.data : undefined + + if (completion.status !== ASYNC_TOOL_CONFIRMATION_STATUS.background) { + const status = + completion.status === MothershipStreamV1ToolOutcome.success + ? 'completed' + : completion.status === MothershipStreamV1ToolOutcome.cancelled + ? 'cancelled' + : 'failed' + try { + const updated = await replaceTerminalAsyncToolCallResult({ + toolCallId, + status, + result: data ?? null, + error: succeeded ? null : message, + }) + if (!updated) { + logger.warn('Client tool row was no longer terminal during safe payload update', { + toolCallId, + }) + } + } catch (error) { + logger.warn('Failed to persist projected client tool result', { + toolCallId, + error: getErrorMessage(error), + }) + } + } + + return { + status: completion.status, + message, + ...(data !== undefined ? { data } : {}), + } +} + +interface WaitForWorkflowToolCompletionOptions { + toolCallId: string + workflowId?: string + timeoutMs: number + abortSignal?: AbortSignal + registry?: ResolvedSecretTraceRegistry +} + +function getCompletionExecutionId(completion: AsyncTerminalCompletionSnapshot): string | undefined { + if (!isPlainRecord(completion.data)) return undefined + return typeof completion.data.executionId === 'string' && completion.data.executionId.length > 0 + ? completion.data.executionId + : undefined +} + +function structuralWorkflowCompletion( + status: AsyncTerminalCompletionSnapshot['status'], + workflowId?: string, + executionId?: string +): AsyncTerminalCompletionSnapshot { + return { + status, + message: getWorkflowToolCompletionMessage(status), + data: createStructuralWorkflowToolCompletionData(status, workflowId, executionId), + } +} + +/** + * Restores a client-run workflow result from the bound server execution log. + * The browser confirmation is only a wakeup and structural identity carrier. + */ +export async function waitForWorkflowToolCompletion({ + toolCallId, + workflowId, + timeoutMs, + abortSignal, + registry, +}: WaitForWorkflowToolCompletionOptions): Promise { + const finishPendingActivation = registry?.beginPendingActivation() + let completion: AsyncTerminalCompletionSnapshot | null = null + let trustedExecution: Awaited> = null + + try { + completion = await waitForToolCompletion(toolCallId, timeoutMs, abortSignal) + if (!completion) { + registry?.markIncomplete() + return null + } + + const executionId = getCompletionExecutionId(completion) + if ( + completion.status === ASYNC_TOOL_CONFIRMATION_STATUS.background || + !workflowId || + !executionId + ) { + registry?.markIncomplete() + return structuralWorkflowCompletion(completion.status, workflowId, executionId) + } + + try { + trustedExecution = await getTrustedWorkflowToolExecution(executionId, workflowId, toolCallId) + } catch (error) { + logger.warn('Failed to restore bound workflow tool execution', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + + if (!trustedExecution || !trustedExecution.provenance.complete) { + registry?.markIncomplete() + return structuralWorkflowCompletion(completion.status, workflowId, executionId) + } + + if (!registry) { + return structuralWorkflowCompletion( + getWorkflowToolConfirmationStatus(trustedExecution.status), + workflowId, + executionId + ) + } + + try { + const imported = await registry.importCrossingProvenance( + trustedExecution.provenance, + { + ...(Object.hasOwn(trustedExecution, 'finalOutput') + ? { finalOutput: trustedExecution.finalOutput } + : {}), + blockLogs: trustedExecution.blockLogs, + ...(trustedExecution.error !== undefined ? { error: trustedExecution.error } : {}), + }, + { trusted: true } + ) + if (!imported) registry.markIncomplete() + } catch (error) { + registry.markIncomplete() + logger.warn('Failed to import bound workflow provenance', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + } finally { + finishPendingActivation?.() + } + + if (!completion || !trustedExecution || !workflowId) return completion + + const executionId = trustedExecution.executionId + const status = getWorkflowToolConfirmationStatus(trustedExecution.status) + const genericMessage = getWorkflowToolCompletionMessage(status) + const rawData: Record = { + success: status === MothershipStreamV1ToolOutcome.success, + workflowId, + executionId, + ...(Object.hasOwn(trustedExecution, 'finalOutput') + ? { output: trustedExecution.finalOutput } + : {}), + logs: trustedExecution.blockLogs, + ...(trustedExecution.error !== undefined ? { error: trustedExecution.error } : {}), + ...(status === MothershipStreamV1ToolOutcome.cancelled + ? { reason: 'user_cancelled', cancelledByUser: true } + : {}), + } + const projected = projectToolResultForCopilot( + { + success: status === MothershipStreamV1ToolOutcome.success, + output: rawData, + ...(status !== MothershipStreamV1ToolOutcome.success + ? { error: trustedExecution.error ?? genericMessage } + : {}), + }, + registry + ) + const projectedData = isPlainRecord(projected.output) ? projected.output : {} + const data = { + ...projectedData, + ...createStructuralWorkflowToolCompletionData(status, workflowId, executionId), + } + const message = + status === MothershipStreamV1ToolOutcome.success + ? genericMessage + : Object.hasOwn(projected, 'output') && projected.error + ? projected.error + : genericMessage + + try { + const updated = await replaceTerminalAsyncToolCallResult({ + toolCallId, + status: trustedExecution.status, + result: data, + error: status === MothershipStreamV1ToolOutcome.success ? null : message, + }) + if (!updated) { + logger.warn('Bound workflow tool row was no longer terminal during safe payload update', { + toolCallId, + workflowId, + executionId, + }) + } + } catch (error) { + logger.warn('Failed to persist projected workflow tool result', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + + return { status, message, data } +} diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 7bd75dda22e..17106c64ce3 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -53,7 +53,7 @@ import { setTerminalToolCallState, } from '@/lib/copilot/request/tool-call-state' import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files' -import { projectFunctionResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' import { maybeWriteOutputToTable, @@ -559,8 +559,7 @@ async function executeToolAndReportInner( return terminalCompletionFromToolCall(toolCall) } if (abortRequested(context, execContext, options)) { - const copilotResult = projectFunctionResultForCopilot( - toolCall.name, + const copilotResult = projectToolResultForCopilot( result, execContext.resolvedSecretTraceRegistry ) @@ -661,8 +660,7 @@ async function executeToolAndReportInner( endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' }) return cancelledCompletion('Request aborted during tool post-processing') } - const copilotResult = projectFunctionResultForCopilot( - toolCall.name, + const copilotResult = projectToolResultForCopilot( result, execContext.resolvedSecretTraceRegistry ) @@ -772,6 +770,7 @@ async function executeToolAndReportInner( toolCall.name, toolCall.params, result, + copilotResult, execContext.chatId, options?.onEvent, () => abortRequested(context, execContext, options) @@ -788,8 +787,7 @@ async function executeToolAndReportInner( }) } catch (error) { const thrownMessage = toError(error).message - const copilotError = projectFunctionResultForCopilot( - toolCall.name, + const copilotError = projectToolResultForCopilot( { success: false, error: thrownMessage }, execContext.resolvedSecretTraceRegistry ) diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 5fe59ef72a2..048fff75196 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -7,6 +7,7 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' @@ -342,14 +343,18 @@ export async function maybeWriteOutputToFile( } } catch (err) { const message = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + message, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write tool output to file', { toolName, outputPaths: outputFiles.map((file) => file.path), - error: message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotOutputFileOutcome, CopilotOutputFileOutcome.Failed) span.addEvent(TraceEvent.CopilotOutputFileError, { - [TraceAttr.ErrorMessage]: message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 78096cdc61b..f8cac22e132 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -33,7 +33,11 @@ import type { StreamEvent, ToolCallState } from '@/lib/copilot/request/types' function makeContext() { const context = createStreamingContext({ runId: 'run-1' }) - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + promptSurfaceAvailable: true, + autoAllowed: new Set(), + } context.trace = new TraceCollector() return context } @@ -91,6 +95,18 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it('ignores saved auto-allow for a secret-bearing code call', () => { + const context = makeContext() + context.toolPermissions.autoAllowed.add('function_execute') + + expect( + toolCallNeedsApproval('function_execute', context, {}, false, { + language: 'javascript', + code: 'return {{API_KEY}}', + }) + ).toBe(true) + }) + it('never gates a non-interactive run, which has nobody to answer the prompt', () => { expect( toolCallNeedsApproval('terminal', makeContext(), { interactive: false }, false, runCall) @@ -103,6 +119,18 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it('still gates secret-bearing code when the permission surface is disabled', () => { + const context = makeContext() + context.toolPermissions.enabled = false + + expect( + toolCallNeedsApproval('function_execute', context, {}, false, { + language: 'javascript', + code: 'return {{API_KEY}}', + }) + ).toBe(true) + }) + it('gates a resolved integration operation off the frame Go stamped', () => { // gmail_read_v2 is request-local: it is not in the catalog at all, so the // only thing marking it is the awaiting_approval status on the frame. @@ -281,6 +309,27 @@ describe('runGatedToolExecution', () => { expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) }) + it.each(['allow_chat', 'always_allow'] as const)( + 'refuses a persisted %s decision for a secret-bearing code call', + async (decision) => { + const context = makeContext() + const toolCall = makeToolCall() + toolCall.name = 'function_execute' + toolCall.params = { language: 'javascript', code: 'return {{API_KEY}}' } + const execute = vi.fn().mockResolvedValue({ status: 'success' }) + waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision }) + + const signal = await gate(context, toolCall, execute, []) + + expect(execute).not.toHaveBeenCalled() + expect(signal.status).toBe('success') + expect(toolCall.result?.output).toMatchObject({ + reason: 'secret_permission_requires_one_time_allow', + }) + expect(context.toolPermissions.autoAllowed.has('function_execute')).toBe(false) + } + ) + it('does not suppress later prompts for a one-off allow', async () => { const context = makeContext() const toolCall = makeToolCall() diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts index a9829c8c155..050cac3b5c2 100644 --- a/apps/sim/lib/copilot/request/tools/permission.ts +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -16,6 +16,7 @@ import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { decisionAllowsExecution, decisionSuppressesFuturePrompts, + TOOL_PERMISSION_DECISION, waitForToolPermissionDecision, } from '@/lib/copilot/persistence/tool-permission' import { withCopilotSpan } from '@/lib/copilot/request/otel' @@ -27,6 +28,7 @@ import type { ToolCallState, } from '@/lib/copilot/request/types' import { getToolEntry, toolRequiresApproval } from '@/lib/copilot/tool-executor' +import { getToolSecretMountNames } from '@/lib/copilot/tools/secret-mount' const logger = createLogger('CopilotToolPermissionGate') @@ -71,9 +73,12 @@ export function toolCallNeedsApproval( /** The call's arguments, for a tool whose gate depends on what it is doing. */ args?: Record ): boolean { - if (!context.toolPermissions.enabled) return false if (options.interactive === false) return false + const mountsSecrets = getToolSecretMountNames(toolName, args).length > 0 + if (mountsSecrets) return true + if (!context.toolPermissions.enabled) return false + if (!frameRequestsApproval) { if (!toolRequiresApproval(toolName)) return false if (toolName === TERMINAL_TOOL_NAME && !terminalOperationNeedsApproval(args)) return false @@ -108,6 +113,14 @@ function noPromptOutput(toolName: string) { } } +function persistentSecretPermissionOutput(toolName: string) { + return { + skipped: true, + reason: 'secret_permission_requires_one_time_allow', + message: `${toolName} requested secrets and requires a one-time Allow decision. Nothing was executed.`, + } +} + /** * Tell the client how a gated call ended without executing. * @@ -272,7 +285,30 @@ export function runGatedToolExecution( span.setAttribute(TraceAttr.CopilotAsyncToolPermissionDecision, decision.decision) - if (decisionSuppressesFuturePrompts(decision.decision)) { + const mountsSecrets = getToolSecretMountNames(toolName, args).length > 0 + if ( + mountsSecrets && + (decision.decision === TOOL_PERMISSION_DECISION.allow_chat || + decision.decision === TOOL_PERMISSION_DECISION.always_allow) + ) { + const output = persistentSecretPermissionOutput(toolName) + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.skipped, + output, + }) + markToolResultSeen(toolCallId) + await emitGateResult( + toolCallId, + toolName, + executor, + MothershipStreamV1ToolOutcome.skipped, + output, + options + ) + return { status: MothershipStreamV1ToolOutcome.success, message: output.message } + } + + if (decisionSuppressesFuturePrompts(decision.decision) && !mountsSecrets) { // Same-turn effect: a second call to this tool later in the turn must // not re-prompt. The durable write (chat row or user settings) happens // in the endpoint. diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts index 6b96267ef9e..fc8a307b05b 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -2,10 +2,10 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { FunctionExecute, Read, RunCode } from '@/lib/copilot/generated/tool-catalog-v1' +import { FunctionExecute, RunCode } from '@/lib/copilot/generated/tool-catalog-v1' import { - FUNCTION_RESULT_OMITTED_ERROR, - projectFunctionResultForCopilot, + projectToolResultForCopilot, + TOOL_RESULT_OMITTED_ERROR, } from '@/lib/copilot/request/tools/resolved-secret-result' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -19,7 +19,7 @@ function createRegistry(): ResolvedSecretTraceRegistry { ]) } -describe('projectFunctionResultForCopilot', () => { +describe('projectToolResultForCopilot', () => { it.each([FunctionExecute.id, RunCode.id])( 'projects active exact and embedded secrets for %s without mutating runtime output', (toolName) => { @@ -35,7 +35,7 @@ describe('projectFunctionResultForCopilot', () => { } const runtimeSnapshot = structuredClone(runtimeResult) - expect(projectFunctionResultForCopilot(toolName, runtimeResult, registry)).toEqual({ + expect(projectToolResultForCopilot(runtimeResult, registry)).toEqual({ success: true, output: { result: '{{SECRET}}', @@ -52,8 +52,7 @@ describe('projectFunctionResultForCopilot', () => { registry.recordResolved('SECRET', 'secret-value') expect( - projectFunctionResultForCopilot( - FunctionExecute.id, + projectToolResultForCopilot( { success: false, output: { stdout: 'printed secret-value' }, @@ -73,8 +72,7 @@ describe('projectFunctionResultForCopilot', () => { registry.recordResolved('SECRET', 'secret-value') expect( - projectFunctionResultForCopilot( - FunctionExecute.id, + projectToolResultForCopilot( { success: true, output: { 'prefix-secret-value': 'safe' }, @@ -87,8 +85,7 @@ describe('projectFunctionResultForCopilot', () => { }) expect( - projectFunctionResultForCopilot( - FunctionExecute.id, + projectToolResultForCopilot( { success: true, output: { 'secret-value': 'first', '{{SECRET}}': 'second' }, @@ -110,13 +107,9 @@ describe('projectFunctionResultForCopilot', () => { registry.recordResolved('BRACE', '{') registry.recordResolved('JOINED', 'ac') - expect( - projectFunctionResultForCopilot( - FunctionExecute.id, - { success: true, output: 'aBc' }, - registry - ) - ).toEqual({ success: true }) + expect(projectToolResultForCopilot({ success: true, output: 'aBc' }, registry)).toEqual({ + success: true, + }) }) it('keeps the control error safe from active one-character values', () => { @@ -125,8 +118,7 @@ describe('projectFunctionResultForCopilot', () => { ]) registry.recordResolved('F_SECRET', 'F') - const projected = projectFunctionResultForCopilot( - FunctionExecute.id, + const projected = projectToolResultForCopilot( { success: false, output: { F: 'first', '': 'second' }, @@ -147,11 +139,7 @@ describe('projectFunctionResultForCopilot', () => { const encoded = Buffer.from('secret-value').toString('base64') expect( - projectFunctionResultForCopilot( - FunctionExecute.id, - { success: true, output: { result: encoded } }, - registry - ) + projectToolResultForCopilot({ success: true, output: { result: encoded } }, registry) ).toEqual({ success: true, output: { result: encoded } }) }) @@ -162,7 +150,7 @@ describe('projectFunctionResultForCopilot', () => { output: { result: 'secret-value', stdout: '' }, } - expect(projectFunctionResultForCopilot(FunctionExecute.id, result, registry)).toEqual(result) + expect(projectToolResultForCopilot(result, registry)).toEqual(result) }) it.each([ @@ -177,8 +165,7 @@ describe('projectFunctionResultForCopilot', () => { ], ])('fails closed for %s provenance without changing structural fields', (_label, registry) => { expect( - projectFunctionResultForCopilot( - FunctionExecute.id, + projectToolResultForCopilot( { success: false, output: { result: 'possibly-secret' }, @@ -189,16 +176,44 @@ describe('projectFunctionResultForCopilot', () => { ) ).toEqual({ success: false, - error: FUNCTION_RESULT_OMITTED_ERROR, - resources: [{ type: 'file', id: 'file-1', title: 'report.txt' }], + error: TOOL_RESULT_OMITTED_ERROR, }) }) - it('does not project unrelated tool results', () => { + it('projects Copilot-visible resource metadata without changing the runtime result', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const result = { + success: true, + resources: [{ type: 'file' as const, id: 'file-secret-value', title: 'secret-value.txt' }], + } + + expect(projectToolResultForCopilot(result, registry)).toEqual({ + success: true, + resources: [{ type: 'file', id: 'file-secret-value', title: '{{SECRET}}.txt' }], + }) + expect(result.resources[0]).toEqual({ + type: 'file', + id: 'file-secret-value', + title: 'secret-value.txt', + }) + }) + + it('projects every tool result once provenance is active', () => { const registry = createRegistry() registry.recordResolved('SECRET', 'secret-value') const result = { success: true, output: 'secret-value' } - expect(projectFunctionResultForCopilot(Read.id, result, registry)).toBe(result) + expect(projectToolResultForCopilot(result, registry)).toEqual({ + success: true, + output: '{{SECRET}}', + }) + expect(result).toEqual({ success: true, output: 'secret-value' }) + }) + + it('omits every tool result when no trusted provenance registry exists', () => { + expect( + projectToolResultForCopilot({ success: true, output: 'possibly-secret' }, undefined) + ).toEqual({ success: true }) }) }) diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts index ffff7dc57c9..b7a3b1f4e89 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts @@ -1,5 +1,5 @@ -import { omit } from '@sim/utils/object' -import { FunctionExecute, RunCode } from '@/lib/copilot/generated/tool-catalog-v1' +import { isPlainRecord, omit } from '@sim/utils/object' +import type { MothershipResource } from '@/lib/copilot/resources/types' import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' import { containsResolvedSecret, @@ -10,22 +10,56 @@ import { } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -export const FUNCTION_RESULT_OMITTED_ERROR = 'Function result omitted' +export const TOOL_RESULT_OMITTED_ERROR = 'Tool result omitted' -function isFunctionSandboxTool(toolName: string): boolean { - return toolName === FunctionExecute.id || toolName === RunCode.id +function omitContent(result: ToolExecutionResult): ToolExecutionResult { + return omit(result, ['output', 'error', 'resources']) } -function omitContent(result: ToolExecutionResult): ToolExecutionResult { - return omit(result, ['output', 'error']) +function resourceContent(resources: MothershipResource[]): Array<{ title: string; path?: string }> { + return resources.map((resource) => ({ + title: resource.title, + ...(resource.path !== undefined ? { path: resource.path } : {}), + })) +} + +function restoreProjectedResources( + resources: MothershipResource[], + projectedContent: unknown +): MothershipResource[] | undefined { + if (!Array.isArray(projectedContent) || projectedContent.length !== resources.length) { + return undefined + } + + const projectedResources: MothershipResource[] = [] + for (let index = 0; index < resources.length; index += 1) { + const content = projectedContent[index] + if ( + !isPlainRecord(content) || + typeof content.title !== 'string' || + (content.path !== undefined && typeof content.path !== 'string') + ) { + return undefined + } + + const resource = resources[index] + projectedResources.push({ + type: resource.type, + id: resource.id, + title: content.title, + ...(content.path !== undefined ? { path: content.path } : {}), + }) + } + + return projectedResources } /** Returns a nonempty control error that cannot contain any active literal. */ function createSafeControlError(matcher: ResolvedSecretMatcher | undefined): string { - if (!matcher) return FUNCTION_RESULT_OMITTED_ERROR + if (!matcher) return TOOL_RESULT_OMITTED_ERROR try { - const projected = sanitizeResolvedSecretString(FUNCTION_RESULT_OMITTED_ERROR, matcher) + const projected = sanitizeResolvedSecretString(TOOL_RESULT_OMITTED_ERROR, matcher) if (projected.length > 0 && !containsResolvedSecret(projected, matcher)) return projected } catch {} @@ -50,15 +84,13 @@ function omittedResult( } /** - * Projects only the Function sandbox content that can cross into Copilot. - * Runtime output remains local and unchanged for post-processing and resource side effects. + * Projects terminal tool content before it can cross back into Copilot. + * Runtime output remains unchanged for raw post-processing and context updates. */ -export function projectFunctionResultForCopilot( - toolName: string, +export function projectToolResultForCopilot( result: ToolExecutionResult, registry: ResolvedSecretTraceRegistry | undefined ): ToolExecutionResult { - if (!isFunctionSandboxTool(toolName)) return result if (!registry?.isComplete()) return omittedResult(result, undefined) let matcher: ResolvedSecretMatcher | undefined @@ -69,6 +101,7 @@ export function projectFunctionResultForCopilot( const content: Record = {} if (Object.hasOwn(result, 'output')) content.output = result.output if (Object.hasOwn(result, 'error')) content.error = result.error + if (result.resources !== undefined) content.resources = resourceContent(result.resources) const projection = projectResolvedSecretContent(content, matcher) if (!projection.safe || !projection.value || typeof projection.value !== 'object') { return omittedResult(result, matcher) @@ -80,6 +113,11 @@ export function projectFunctionResultForCopilot( if (Object.hasOwn(projectedContent, 'error')) { projected.error = String(projectedContent.error) } + if (result.resources !== undefined) { + const resources = restoreProjectedResources(result.resources, projectedContent.resources) + if (!resources) return omittedResult(result, matcher) + projected.resources = resources + } if (!projected.success && !projected.error) { projected.error = createSafeControlError(matcher) } @@ -88,3 +126,14 @@ export function projectFunctionResultForCopilot( return omittedResult(result, matcher) } } + +/** Projects an error before post-processing can attach it to application logs or OTel events. */ +export function projectToolErrorMessageForCopilot( + error: string, + registry: ResolvedSecretTraceRegistry | undefined +): string { + return ( + projectToolResultForCopilot({ success: false, error }, registry).error ?? + TOOL_RESULT_OMITTED_ERROR + ) +} diff --git a/apps/sim/lib/copilot/request/tools/resources.ts b/apps/sim/lib/copilot/request/tools/resources.ts index 361f4105201..88ee1f01a07 100644 --- a/apps/sim/lib/copilot/request/tools/resources.ts +++ b/apps/sim/lib/copilot/request/tools/resources.ts @@ -29,6 +29,7 @@ export async function handleResourceSideEffects( toolName: string, params: Record | undefined, result: ToolCallResult, + projectedResult: ToolCallResult, chatId: string, onEvent: ((event: StreamEvent) => void | Promise) | undefined, isAborted: () => boolean @@ -57,6 +58,11 @@ export async function handleResourceSideEffects( if (hasDeleteCapability(toolName)) { const deleted = extractDeletedResourcesFromToolResult(toolName, params, result.output) + const projectedDeleted = extractDeletedResourcesFromToolResult( + toolName, + params, + projectedResult.output + ) if (deleted.length > 0) { isDeleteOp = true removedCount = deleted.length @@ -71,13 +77,19 @@ export async function handleResourceSideEffects( }) }) - for (const resource of deleted) { + for (let index = 0; index < deleted.length; index += 1) { if (isAborted()) break + const resource = deleted[index] + const projected = projectedDeleted[index] await onEvent?.({ type: MothershipStreamV1EventType.resource, payload: { op: MothershipStreamV1ResourceOp.remove, - resource: { type: resource.type, id: resource.id, title: resource.title }, + resource: { + type: resource.type, + id: resource.id, + title: projected?.title ?? '', + }, }, }) } @@ -85,12 +97,29 @@ export async function handleResourceSideEffects( } if (!isDeleteOp && !isAborted()) { - const resources = + const rawResources = result.resources && result.resources.length > 0 ? result.resources : isResourceToolName(toolName) ? extractResourcesFromToolResult(toolName, params, result.output) : [] + const projectedResources = + result.resources && result.resources.length > 0 + ? (projectedResult.resources ?? []) + : isResourceToolName(toolName) + ? extractResourcesFromToolResult(toolName, params, projectedResult.output) + : [] + const resources = + projectedResources.length === rawResources.length + ? rawResources.map((resource, index) => ({ + type: resource.type, + id: resource.id, + title: projectedResources[index].title, + ...(projectedResources[index].path !== undefined + ? { path: projectedResources[index].path } + : {}), + })) + : [] if (resources.length > 0) { upsertedCount = resources.length diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index c9ab80ae41b..f90edf31e66 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -2,12 +2,14 @@ * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockGetTableById, mockReplaceTableRows } = vi.hoisted(() => ({ +const { mockGetTableById, mockReplaceTableRows, mockSpanAddEvent } = vi.hoisted(() => ({ mockGetTableById: vi.fn(), mockReplaceTableRows: vi.fn(), + mockSpanAddEvent: vi.fn(), })) vi.mock('@/lib/table/service', () => ({ @@ -23,7 +25,7 @@ vi.mock('@/lib/copilot/request/otel', () => ({ _name: string, _attrs: Record | undefined, fn: (span: unknown) => Promise - ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }), + ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: mockSpanAddEvent }), })) import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' @@ -32,6 +34,13 @@ import { maybeWriteReadCsvToTable, } from '@/lib/copilot/request/tools/tables' import type { ExecutionContext } from '@/lib/copilot/request/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const tableLogger = vi.mocked(loggerMock.createLogger).mock.results[ + vi + .mocked(loggerMock.createLogger) + .mock.calls.findIndex(([name]) => name === 'CopilotToolResultTables') +]?.value function buildTable(overrides: Partial = {}): TableDefinition { return { @@ -172,6 +181,27 @@ describe('maybeWriteOutputToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('Row 1: name is required') }) + + it('keeps raw errors for terminal projection but projects application logs and OTel events', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, + ]) + registry.recordResolved('SECRET', 'secret-value') + mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ name: 'secret-value' }] } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.error).toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') + }) }) describe('maybeWriteReadCsvToTable', () => { @@ -251,4 +281,25 @@ describe('maybeWriteReadCsvToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('Row 1: name is required') }) + + it('projects active secret literals in CSV-import log and OTel errors', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, + ]) + registry.recordResolved('SECRET', 'secret-value') + mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) + + const result = await maybeWriteReadCsvToTable( + ReadTool.id, + { outputTable: 'tbl_1', path: 'files/people.csv' }, + { success: true, output: { content: 'name\nsecret-value' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.error).toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') + }) }) diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index 5d1aaf310a3..053c37de141 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -9,6 +9,7 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import type { RowData, TableDefinition } from '@/lib/table' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' @@ -151,18 +152,23 @@ export async function maybeWriteOutputToTable( }, } } catch (err) { + const rawMessage = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + rawMessage, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write tool output to table', { toolName, outputTable, - error: toError(err).message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed) span.addEvent(TraceEvent.CopilotTableError, { - [TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, - error: `Failed to write to table: ${toError(err).message}`, + error: `Failed to write to table: ${rawMessage}`, } } } @@ -281,18 +287,23 @@ export async function maybeWriteReadCsvToTable( }, } } catch (err) { + const rawMessage = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + rawMessage, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write read output to table', { toolName, outputTable, - error: toError(err).message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed) span.addEvent(TraceEvent.CopilotTableError, { - [TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, - error: `Failed to import into table: ${toError(err).message}`, + error: `Failed to import into table: ${rawMessage}`, } } } diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index bf4908896db..e54c54341d1 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -179,6 +179,7 @@ export interface StreamingContext { */ toolPermissions: { enabled: boolean + promptSurfaceAvailable: boolean autoAllowed: Set } } diff --git a/apps/sim/lib/copilot/secret-mount-policy.test.ts b/apps/sim/lib/copilot/secret-mount-policy.test.ts new file mode 100644 index 00000000000..49d355cc39d --- /dev/null +++ b/apps/sim/lib/copilot/secret-mount-policy.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + applySecretMountPolicy, + normalizeSecretMountPolicy, +} from '@/lib/copilot/secret-mount-policy' + +describe('normalizeSecretMountPolicy', () => { + it('defaults missing legacy policy data to all and fails malformed scopes closed', () => { + expect(normalizeSecretMountPolicy()).toEqual({ secretScope: 'all', mountedSecrets: [] }) + expect( + normalizeSecretMountPolicy({ secretScope: 'unknown', mountedSecrets: ['SECRET'] }) + ).toEqual({ secretScope: 'selected', mountedSecrets: [] }) + }) + + it('canonicalizes a selected names-only allowlist', () => { + expect( + normalizeSecretMountPolicy({ + secretScope: 'selected', + mountedSecrets: [' B ', 'A', 'B', '', 42], + }) + ).toEqual({ secretScope: 'selected', mountedSecrets: ['B', 'A'] }) + }) + + it('preserves selected with an empty list as no access', () => { + expect(normalizeSecretMountPolicy({ secretScope: 'selected' })).toEqual({ + secretScope: 'selected', + mountedSecrets: [], + }) + }) +}) + +describe('applySecretMountPolicy', () => { + it('allows every explicit reference under the all policy', () => { + expect(applySecretMountPolicy(['B', ' A ', 'B'])).toEqual(['B', 'A']) + }) + + it('returns exact explicit references under a selected policy', () => { + expect( + applySecretMountPolicy(['B'], { + secretScope: 'selected', + mountedSecrets: ['A', 'B'], + }) + ).toEqual(['B']) + }) + + it('fails atomically when selected policy denies any reference', () => { + expect(() => + applySecretMountPolicy(['A', 'B'], { + secretScope: 'selected', + mountedSecrets: ['A'], + }) + ).toThrow('Secret access is not allowed for: B') + }) +}) diff --git a/apps/sim/lib/copilot/secret-mount-policy.ts b/apps/sim/lib/copilot/secret-mount-policy.ts new file mode 100644 index 00000000000..3a7305a8e8b --- /dev/null +++ b/apps/sim/lib/copilot/secret-mount-policy.ts @@ -0,0 +1,75 @@ +export type SecretMountScope = 'all' | 'selected' + +export interface SecretMountPolicy { + secretScope: SecretMountScope + mountedSecrets: string[] +} + +export const MAX_SECRET_MOUNT_NAMES = 100 +export const MAX_SECRET_MOUNT_NAME_LENGTH = 1024 + +export const DEFAULT_SECRET_MOUNT_POLICY: SecretMountPolicy = { + secretScope: 'all', + mountedSecrets: [], +} + +interface SecretMountPolicyInput { + secretScope?: unknown + mountedSecrets?: unknown +} + +function normalizeSecretNames(value: unknown): string[] { + if (!Array.isArray(value)) return [] + + const names = new Set() + for (const candidate of value) { + if (typeof candidate !== 'string') continue + const name = candidate.trim() + if (name) names.add(name) + } + return [...names] +} + +/** + * Normalizes persisted or legacy policy data. A missing scope uses the backwards-compatible + * `all` policy; an explicit invalid scope fails closed. Selected policies keep a canonical, + * de-duplicated names-only allowlist. + */ +export function normalizeSecretMountPolicy( + input?: SecretMountPolicyInput | null +): SecretMountPolicy { + if (input?.secretScope === undefined || input.secretScope === 'all') { + return { ...DEFAULT_SECRET_MOUNT_POLICY } + } + + if (input.secretScope !== 'selected') { + return { secretScope: 'selected', mountedSecrets: [] } + } + + return { + secretScope: 'selected', + mountedSecrets: normalizeSecretNames(input.mountedSecrets), + } +} + +/** + * Applies a normalized headless allowlist to explicitly referenced secret + * names. A selected policy denies the whole request when any reference is not + * listed so code never runs with a surprising partial environment. + */ +export function applySecretMountPolicy( + requestedNames: readonly string[], + input?: SecretMountPolicyInput | null +): string[] { + const policy = normalizeSecretMountPolicy(input) + const requested = normalizeSecretNames(requestedNames) + if (policy.secretScope === 'all') return requested + + const allowed = new Set(policy.mountedSecrets) + const denied = requested.filter((name) => !allowed.has(name)) + if (denied.length > 0) { + throw new Error(`Secret access is not allowed for: ${denied.join(', ')}`) + } + + return requested +} diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 2342f31efbe..30b5d8d4f17 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -2,11 +2,13 @@ * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -const { isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({ +const { getToolEntry, isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({ + getToolEntry: vi.fn(), isKnownTool: vi.fn(), isSimExecuted: vi.fn(), isClientExecuted: vi.fn(), @@ -17,6 +19,7 @@ const { executeAppTool } = vi.hoisted(() => ({ })) vi.mock('./router', () => ({ + getToolEntry, isKnownTool, isSimExecuted, isClientExecuted, @@ -28,10 +31,87 @@ vi.mock('@/tools', () => ({ import { clearHandlers, executeTool, registerHandler } from './executor' +const toolExecutorLogger = vi.mocked(loggerMock.createLogger).mock.results[ + vi.mocked(loggerMock.createLogger).mock.calls.findIndex(([name]) => name === 'ToolExecutor') +]?.value + describe('copilot tool executor fallback', () => { beforeEach(() => { vi.clearAllMocks() clearHandlers() + getToolEntry.mockReturnValue(undefined) + }) + + it('enforces catalog-required permissions before dispatch and fails closed when absent', async () => { + getToolEntry.mockReturnValue({ requiredPermission: 'write' }) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('function_execute', handler) + + await expect( + executeTool('function_execute', { code: 'return 1' }, { userId: 'user-1', workflowId: '' }) + ).resolves.toEqual({ + success: false, + error: + "Permission denied: function_execute requires write access. You have 'none' permission.", + }) + await expect( + executeTool( + 'function_execute', + { code: 'return 1' }, + { userId: 'user-1', workflowId: '', userPermission: 'read' } + ) + ).resolves.toEqual({ + success: false, + error: + "Permission denied: function_execute requires write access. You have 'read' permission.", + }) + expect(handler).not.toHaveBeenCalled() + }) + + it('dispatches catalog-protected tools when the current permission satisfies the requirement', async () => { + getToolEntry.mockReturnValue({ requiredPermission: 'write' }) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true, output: 'ok' }) + registerHandler('function_execute', handler) + + await expect( + executeTool( + 'function_execute', + { code: 'return 1' }, + { userId: 'user-1', workflowId: '', userPermission: 'write' } + ) + ).resolves.toEqual({ success: true, output: 'ok' }) + expect(handler).toHaveBeenCalledOnce() + }) + + it('projects resolved secrets before logging registered handler failures', async () => { + const secret = 'mounted-secret-value' + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-secret' }, + ]) + registry.recordResolved('API_KEY', secret) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + registerHandler('throwing_tool', async () => { + throw new Error(`Provider reflected ${secret}`) + }) + + await expect( + executeTool('throwing_tool', {}, { userId: 'user-1', resolvedSecretTraceRegistry: registry }) + ).resolves.toEqual({ success: false, error: `Provider reflected ${secret}` }) + + expect(toolExecutorLogger?.error).toHaveBeenCalledWith('Tool execution failed', { + toolId: 'throwing_tool', + error: 'Provider reflected {{API_KEY}}', + abortSignalAborted: false, + }) + expect(JSON.stringify(toolExecutorLogger?.error.mock.calls)).not.toContain(secret) }) it('falls back to app tool executor for dynamic sim tools', async () => { diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 3b9efa8438b..6488b695f25 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -1,8 +1,10 @@ import { createLogger } from '@sim/logger' +import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { executeTool as executeAppTool } from '@/tools' -import { isClientExecuted, isKnownTool, isSimExecuted } from './router' +import { getToolEntry, isClientExecuted, isKnownTool, isSimExecuted } from './router' import type { ToolCallDescriptor, ToolExecutionContext, @@ -44,6 +46,20 @@ export async function executeTool( params: Record, context: ToolExecutionContext ): Promise { + const requiredPermission = getToolEntry(toolId)?.requiredPermission + if ( + requiredPermission && + !permissionSatisfies( + (context.userPermission ?? null) as PermissionType | null, + requiredPermission + ) + ) { + return { + success: false, + error: `Permission denied: ${toolId} requires ${requiredPermission} access. You have '${context.userPermission ?? 'none'}' permission.`, + } + } + const normalizedParams = normalizeToolParams(toolId, params, context) // Client-routed tools (e.g. run_workflow) are normally executed in the browser and never @@ -82,7 +98,7 @@ export async function executeTool( const message = toError(error).message logger.error('Tool execution failed', { toolId, - error: message, + error: projectToolErrorMessageForCopilot(message, context.resolvedSecretTraceRegistry), abortSignalAborted: context.abortSignal?.aborted ?? false, }) return { success: false, error: message } diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index a08fda51758..93db4b4eb23 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -1,5 +1,6 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' export interface ToolExecutionContext { @@ -24,7 +25,9 @@ export interface ToolExecutionContext { abortSignal?: AbortSignal userTimezone?: string userPermission?: string - decryptedEnvVars?: Record + secretMountPolicy?: SecretMountPolicy + /** Undefined uses the execution actor; null explicitly disables raw secret mounting. */ + secretActorUserId?: string | null resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry } diff --git a/apps/sim/lib/copilot/tools/client/completion.ts b/apps/sim/lib/copilot/tools/client/completion.ts index ef66300b447..b99cb55cf98 100644 --- a/apps/sim/lib/copilot/tools/client/completion.ts +++ b/apps/sim/lib/copilot/tools/client/completion.ts @@ -26,10 +26,12 @@ export async function reportClientToolCompletion( toolCallId: string, status: AsyncConfirmationStatus, message?: string, - data?: AsyncCompletionData + data?: AsyncCompletionData, + executionId?: string ): Promise { const basePayload = { toolCallId, + ...(executionId ? { executionId } : {}), status, message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), ...(data !== undefined ? { data } : {}), @@ -61,6 +63,7 @@ export async function reportClientToolCompletion( const retryResponse = await send( JSON.stringify({ toolCallId, + ...(executionId ? { executionId } : {}), status, message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), data: dataWithoutLogs, diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts index ac5fff66d70..873497f3407 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts @@ -102,6 +102,7 @@ import { describe('run tool execution cancellation', () => { beforeEach(() => { vi.clearAllMocks() + window.sessionStorage.clear() getCurrentExecutionId.mockReturnValue(null) getWorkflowEntries.mockReturnValue([]) loadExecutionPointer.mockResolvedValue(null) @@ -133,6 +134,7 @@ describe('run tool execution cancellation', () => { it('can report a manual stop using the explicit toolCallId override', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) + getCurrentExecutionId.mockReturnValueOnce('exec-manual') await reportManualRunToolStop('wf-1', 'tool-override') @@ -143,13 +145,14 @@ describe('run tool execution cancellation', () => { body: expect.stringContaining('"toolCallId":"tool-override"'), }) ) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-manual"') }) it('prefers workflow_input, forwards triggerBlockId, and respects useDeployedState', async () => { executeWorkflowWithFullLogging.mockResolvedValueOnce({ success: true, - output: { ok: true }, - logs: [], + output: { token: 'raw-secret-output' }, + logs: [{ output: 'raw-secret-log' }], }) executeRunToolOnClient('tool-2', 'run_workflow', { @@ -172,6 +175,41 @@ describe('run tool execution cancellation', () => { useDraftState: false, }) ) + const executionId = executeWorkflowWithFullLogging.mock.calls[0][0].executionId + await vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + '/api/copilot/confirm', + expect.objectContaining({ + body: expect.stringContaining(`"executionId":"${executionId}"`), + }) + ) + }) + expect(fetch.mock.calls[0][1]?.body).not.toContain('raw-secret') + }) + + it('reports the workflow execution id with terminal error results', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + executeWorkflowWithFullLogging.mockResolvedValueOnce({ + success: false, + output: {}, + error: 'workflow failed', + logs: [], + }) + + executeRunToolOnClient('tool-error', 'run_workflow', { workflowId: 'wf-1' }) + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/copilot/confirm', + expect.objectContaining({ + body: expect.stringContaining('"status":"error"'), + }) + ) + }) + const executionId = executeWorkflowWithFullLogging.mock.calls[0][0].executionId + expect(fetchMock.mock.calls[0][1]?.body).toContain(`"executionId":"${executionId}"`) + expect(fetchMock.mock.calls[0][1]?.body).not.toContain('workflow failed') }) it('treats a tab-local execution pointer as handled in background', async () => { @@ -197,6 +235,33 @@ describe('run tool execution cancellation', () => { body: expect.stringContaining('"status":"background"'), }) ) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-existing"') + }) + + it('strips raw payloads from legacy pending completion recovery', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + loadExecutionPointer.mockResolvedValueOnce({ + workflowId: 'wf-1', + executionId: 'exec-existing', + lastEventId: 7, + }) + window.sessionStorage.setItem( + 'sim:copilot:run-tool-completion:tool-recovered', + JSON.stringify({ + status: 'success', + message: 'legacy raw-secret-error', + data: { output: 'legacy raw-secret-output', logs: ['legacy raw-secret-log'] }, + executionId: 'exec-existing', + }) + ) + + await expect(bindRunToolToExecution('tool-recovered', 'wf-1')).resolves.toBe(true) + + const body = fetchMock.mock.calls[0][1]?.body + expect(body).toContain('"status":"success"') + expect(body).toContain('"executionId":"exec-existing"') + expect(body).not.toContain('raw-secret') }) it('does not recover from shared console rows without a tab-local pointer', async () => { @@ -241,6 +306,7 @@ describe('run tool execution cancellation', () => { }) expect(clearExecutionPointer).not.toHaveBeenCalled() expect(setIsExecuting).toHaveBeenCalledWith('wf-1', false) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-1"') expect(fetchMock).not.toHaveBeenCalledWith( '/api/copilot/confirm', expect.objectContaining({ diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index cd66f64031f..62a7bc15110 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -1,9 +1,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isPlainRecord } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, - type AsyncCompletionData, type AsyncConfirmationStatus, } from '@/lib/copilot/async-runs/lifecycle' import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' @@ -17,6 +17,7 @@ import { CompletionReportError, reportClientToolCompletion as reportCompletion, } from '@/lib/copilot/tools/client/completion' +import { getWorkflowToolCompletionMessage } from '@/lib/copilot/tools/workflow-tools' import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils' import { SSEEventHandlerError, SSEStreamInterruptedError } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution/store' @@ -36,8 +37,7 @@ const PENDING_COMPLETION_STORAGE_PREFIX = 'sim:copilot:run-tool-completion:' interface PendingCompletionReport { status: AsyncConfirmationStatus - message?: string - data?: AsyncCompletionData + executionId?: string } function resolveWorkflowInput(params: Record): unknown { @@ -141,8 +141,11 @@ export async function bindRunToolToExecution( await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + pendingCompletion.status === MothershipStreamV1ToolOutcome.cancelled + ? { reason: 'user_cancelled', cancelledByUser: true } + : undefined, + pendingCompletion.executionId ?? pointer.executionId ) clearPendingCompletionReport(toolCallId) } catch (error) { @@ -160,12 +163,9 @@ export async function bindRunToolToExecution( await reportCompletion( toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.background, - 'Client recovered an existing workflow execution; continuing in background.', - { - workflowId, - executionId: pointer.executionId, - lastEventId: pointer.lastEventId, - } + getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background), + undefined, + pointer.executionId ) } catch (error) { logger.warn('[RunTool] Failed to report recovered execution as background', { @@ -186,8 +186,8 @@ export async function bindRunToolToExecution( * Mirrors staging's RunWorkflowClientTool.handleAccept(): * 1. Execute via executeWorkflowWithFullLogging * 2. Update client tool state directly (success/error) - * 3. Report completion to server via /api/copilot/confirm (Redis), - * where the server-side handler picks it up and tells Go + * 3. Report a structural completion notification; the server restores the + * bound execution result from its log before resuming Copilot */ export function executeRunToolOnClient( toolCallId: string, @@ -246,15 +246,19 @@ export async function reportManualRunToolStop( manuallyStoppedToolCallIds.add(toolCallId) } + const executionId = + useExecutionStore.getState().getCurrentExecutionId(workflowId) ?? + (await loadExecutionPointer(workflowId).catch(() => null))?.executionId + await reportCompletion( toolCallId, MothershipStreamV1ToolOutcome.cancelled, - 'Workflow execution was stopped manually by the user.', + getWorkflowToolCompletionMessage(MothershipStreamV1ToolOutcome.cancelled), { reason: 'user_cancelled', cancelledByUser: true, - workflowId, - } + }, + executionId ) } @@ -347,7 +351,6 @@ async function doExecuteRunTool( const executionId = generateId() setCurrentExecutionId(targetWorkflowId, executionId) saveExecutionPointer({ workflowId: targetWorkflowId, executionId, lastEventId: 0 }) - const executionStartTime = new Date().toISOString() const releaseVisibleExecutionForBackground = () => { const { setCurrentExecutionId: clearExecId, setActiveBlocks } = useExecutionStore.getState() if (activeRunToolByWorkflowId.get(targetWorkflowId) === toolCallId) { @@ -360,12 +363,15 @@ async function doExecuteRunTool( const onPageHide = () => { if (manuallyStoppedToolCallIds.has(toolCallId)) return + const activeExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId navigator.sendBeacon( COPILOT_CONFIRM_API_PATH, new Blob( [ JSON.stringify({ toolCallId, + executionId: activeExecutionId, status: 'background', message: 'Client disconnected, execution continuing server-side', }), @@ -397,6 +403,7 @@ async function doExecuteRunTool( workflowId: targetWorkflowId, workflowInput, executionId, + copilotToolCallId: toolCallId, overrideTriggerType: 'copilot', triggerBlockId, useDraftState, @@ -406,28 +413,16 @@ async function doExecuteRunTool( preserveExecutionOnTerminal: true, }) + const completedExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId + // Determine success (same logic as staging's RunWorkflowClientTool) - let succeeded = true - let errorMessage: string | undefined - try { - if (result && typeof result === 'object' && 'success' in (result as any)) { - succeeded = Boolean((result as any).success) - if (!succeeded) { - errorMessage = (result as any)?.error || (result as any)?.output?.error - } - } else if ( - result && - typeof result === 'object' && - 'execution' in (result as any) && - (result as any).execution - ) { - succeeded = Boolean((result as any).execution.success) - if (!succeeded) { - errorMessage = - (result as any).execution?.error || (result as any).execution?.output?.error - } - } - } catch {} + const succeeded = + isPlainRecord(result) && Object.hasOwn(result, 'success') + ? Boolean(result.success) + : isPlainRecord(result) && isPlainRecord(result.execution) + ? Boolean(result.execution.success) + : true if (manuallyStoppedToolCallIds.has(toolCallId)) { logger.info('[RunTool] Skipping generic completion — already manually stopped', { @@ -438,31 +433,30 @@ async function doExecuteRunTool( logger.info('[RunTool] Workflow execution succeeded', { toolCallId, toolName }) const pendingCompletion = { status: MothershipStreamV1ToolOutcome.success, - message: `Workflow execution completed. Started at: ${executionStartTime}`, - data: buildResultData(result), + executionId: completedExecutionId, } savePendingCompletionReport(toolCallId, pendingCompletion) await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + undefined, + pendingCompletion.executionId ) clearPendingCompletionReport(toolCallId) } else { - const msg = errorMessage || 'Workflow execution failed' - logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName, error: msg }) + logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName }) const pendingCompletion = { status: MothershipStreamV1ToolOutcome.error, - message: msg, - data: buildResultData(result), + executionId: completedExecutionId, } savePendingCompletionReport(toolCallId, pendingCompletion) await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + undefined, + pendingCompletion.executionId ) clearPendingCompletionReport(toolCallId) } @@ -489,7 +483,9 @@ async function doExecuteRunTool( await reportCompletion( toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.background, - 'Client lost local stream processing; workflow execution may still be continuing server-side.' + getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background), + undefined, + err.executionId ?? executionId ) return } @@ -504,7 +500,15 @@ async function doExecuteRunTool( return } logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg }) - await reportCompletion(toolCallId, MothershipStreamV1ToolOutcome.error, msg) + const failedExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId + await reportCompletion( + toolCallId, + MothershipStreamV1ToolOutcome.error, + getWorkflowToolCompletionMessage(MothershipStreamV1ToolOutcome.error), + undefined, + failedExecutionId + ) } } finally { if (typeof window !== 'undefined') { @@ -529,34 +533,3 @@ async function doExecuteRunTool( } } } - -/** - * Extract a structured result payload from the raw execution result - * for the LLM to see the actual workflow output. - */ -function buildResultData(result: unknown): Record | undefined { - if (!result || typeof result !== 'object') return undefined - - const r = result as Record - - if ('success' in r) { - return { - success: r.success, - output: r.output, - logs: r.logs, - error: r.error, - } - } - - if ('execution' in r && r.execution && typeof r.execution === 'object') { - const exec = r.execution as Record - return { - success: exec.success, - output: exec.output, - logs: exec.logs, - error: exec.error, - } - } - - return undefined -} diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 0320fb41f35..cef1cf7e4a9 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ + +import { encryptionMock, encryptionMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -20,6 +22,7 @@ const { mockFetchServableWorkspaceFileBuffer, mockGetSandboxWorkspaceFilePath, mockListWorkspaceFileFolders, + mockMaterializeCopilotCodeSecrets, } = vi.hoisted(() => ({ mockIsFeatureEnabled: vi.fn(), mockGetTableById: vi.fn(), @@ -36,9 +39,11 @@ const { mockFetchServableWorkspaceFileBuffer: vi.fn(), mockGetSandboxWorkspaceFilePath: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), + mockMaterializeCopilotCodeSecrets: vi.fn(), })) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled })) +vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/table/service', () => ({ getTableById: mockGetTableById, listTables: mockListTables, @@ -68,8 +73,14 @@ vi.mock('@/lib/copilot/vfs/path-utils', () => ({ decodeVfsPathSegments: (p: string) => p.split('/'), encodeVfsPathSegments: (s: string[]) => s.join('/'), })) +vi.mock('@/lib/copilot/tools/secret-mount-materializer.server', () => ({ + CopilotCodeSecretAccessError: class CopilotCodeSecretAccessError extends Error {}, + materializeCopilotCodeSecrets: mockMaterializeCopilotCodeSecrets, +})) +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const table = { id: 'tbl_1', @@ -93,25 +104,251 @@ describe('executeFunctionExecute trace-secret provenance', () => { beforeEach(() => { vi.clearAllMocks() mockExecuteTool.mockResolvedValue({ success: true }) + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ envVars: {}, catalogEntries: [] }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' }) }) - it('forwards the registry only through server execution options', async () => { - const resolvedSecretTraceRegistry = { recordResolved: vi.fn() } - - await executeFunctionExecute({ code: 'return {{API_KEY}}' }, { - userId: 'u1', - workspaceId: 'ws_1', - resolvedSecretTraceRegistry, - } as never) + it('mounts only explicit references and imports active provenance out of band', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { + options.resolvedSecretTraceRegistry.recordResolved('API_KEY', 'secret-value') + return { success: true, output: { result: 'secret-value' } } + }) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + const runtimeResult = await executeFunctionExecute( + { + code: 'return {{API_KEY}}', + envVars: { ATTACKER_KEY: 'attacker-value' }, + secretScope: 'all', + mountedSecrets: ['ATTACKER_KEY'], + _context: { resolvedSecretTraceRegistry: 'attacker-value' }, + }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) expect(mockExecuteTool).toHaveBeenCalledWith( 'function_execute', - expect.not.objectContaining({ resolvedSecretTraceRegistry: expect.anything() }), - { resolvedSecretTraceRegistry } + expect.objectContaining({ + envVars: { API_KEY: 'secret-value' }, + secretScope: 'selected', + mountedSecrets: ['API_KEY'], + _context: expect.not.objectContaining({ resolvedSecretTraceRegistry: expect.anything() }), + }), + { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } ) const appParams = mockExecuteTool.mock.calls[0]?.[1] as Record - expect(appParams._context).not.toHaveProperty('resolvedSecretTraceRegistry') expect(JSON.stringify(appParams)).not.toContain('resolvedSecretTraceRegistry') + expect(runtimeResult).toEqual({ success: true, output: { result: 'secret-value' } }) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, + ]) + }) + + it('does not mount direct environment-map or shell-variable access', async () => { + await executeFunctionExecute( + { code: 'return environmentVariables.API_KEY + "$API_KEY"' }, + { userId: 'u1', workflowId: '', workspaceId: 'ws_1' } + ) + + expect(mockMaterializeCopilotCodeSecrets).not.toHaveBeenCalled() + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ envVars: {}, secretScope: 'selected', mountedSecrets: [] }), + { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } + ) + }) + + it('returns the raw runtime result when provenance import fails', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + const runtimeResult = { success: true, output: { result: 'secret-value' } } + mockExecuteTool.mockResolvedValue(runtimeResult) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([], { + userId: 'u1', + workspaceId: 'ws_1', + }) + vi.spyOn(resolvedSecretTraceRegistry, 'importProvenance').mockRejectedValueOnce( + new Error('provenance import failed') + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).resolves.toBe(runtimeResult) + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + }) + + it('fails parallel projections closed until exact mounted provenance is active', async () => { + let completeMaterialization: ((value: unknown) => void) | undefined + mockMaterializeCopilotCodeSecrets.mockReturnValueOnce( + new Promise((resolve) => { + completeMaterialization = resolve + }) + ) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + options.resolvedSecretTraceRegistry.recordResolved('API_KEY', 'secret-value') + return { success: true, output: { result: 'secret-value' } } + }) + + const execution = executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot( + { success: true, output: { result: 'secret-value' } }, + resolvedSecretTraceRegistry + ) + ).toEqual({ success: true }) + + completeMaterialization?.({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + await execution + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, + ]) + expect(mockExecuteTool).toHaveBeenCalledOnce() + }) + + it('does not activate a mounted reference when the Function route rejects before resolution', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + mockExecuteTool.mockResolvedValueOnce({ + success: false, + error: 'Too many sandbox output files requested', + }) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).resolves.toEqual({ + success: false, + error: 'Too many sandbox output files requested', + }) + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([]) + }) + + it('releases pending provenance without activation when mounting is denied', async () => { + mockMaterializeCopilotCodeSecrets.mockRejectedValueOnce(new Error('mount denied')) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).rejects.toThrow('mount denied') + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([]) + expect(mockExecuteTool).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index e064b40d579..3f7b4e5f907 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,4 +1,12 @@ import { createLogger } from '@sim/logger' +import { applySecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { extractCodeSecretNames } from '@/lib/copilot/tools/secret-mount' +import { + CopilotCodeSecretAccessError, + type MaterializedCopilotCodeSecrets, + materializeCopilotCodeSecrets, +} from '@/lib/copilot/tools/secret-mount-materializer.server' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -23,8 +31,8 @@ import { hasCloudStorage, } from '@/lib/uploads/core/storage-service' import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeTool as executeAppTool } from '@/tools' -import type { ToolExecutionContext, ToolExecutionResult } from '../../tool-executor/types' const logger = createLogger('CopilotFunctionExecute') @@ -451,67 +459,112 @@ export async function resolveInputFiles( return sandboxFiles } +async function importMountedProvenance( + source: ResolvedSecretTraceRegistry, + target: ResolvedSecretTraceRegistry | undefined +): Promise { + if (!target) return + + try { + const imported = await target.importProvenance(source.exportProvenance(), { trusted: true }) + if (!imported) target.markIncomplete() + } catch { + target.markIncomplete() + } +} + export async function executeFunctionExecute( params: Record, context: ToolExecutionContext ): Promise { const enrichedParams = { ...params } - - if (context.decryptedEnvVars && Object.keys(context.decryptedEnvVars).length > 0) { - enrichedParams.envVars = { - ...context.decryptedEnvVars, - ...((enrichedParams.envVars as Record) || {}), + const requestedNames = applySecretMountPolicy( + extractCodeSecretNames(params.code, params.language), + context.secretMountPolicy + ) + const completePendingActivation = + requestedNames.length > 0 + ? context.resolvedSecretTraceRegistry?.beginPendingActivation() + : undefined + let mountedRegistry: ResolvedSecretTraceRegistry | undefined + + try { + const secretActorUserId = + context.secretActorUserId === undefined ? context.userId : context.secretActorUserId + let mounted: MaterializedCopilotCodeSecrets = { envVars: {}, catalogEntries: [] } + if (requestedNames.length > 0) { + if (!secretActorUserId) { + throw new CopilotCodeSecretAccessError('Secret access is unavailable for this Copilot run') + } + if (!context.workspaceId) { + throw new CopilotCodeSecretAccessError( + 'A workspace is required to mount secrets into Copilot code' + ) + } + mounted = await materializeCopilotCodeSecrets({ + actorUserId: secretActorUserId, + workspaceId: context.workspaceId, + requestedNames, + }) } - } + mountedRegistry = new ResolvedSecretTraceRegistry(mounted.catalogEntries, { + userId: secretActorUserId ?? context.userId, + ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), + }) - if (context.workspaceId) { - const inputs = enrichedParams.inputs as - | { - files?: CanonicalFileInput[] - directories?: CanonicalDirectoryInput[] - tables?: CanonicalTableInput[] + enrichedParams.envVars = mounted.envVars + enrichedParams.secretScope = 'selected' + enrichedParams.mountedSecrets = requestedNames + + if (context.workspaceId) { + const inputs = enrichedParams.inputs as + | { + files?: CanonicalFileInput[] + directories?: CanonicalDirectoryInput[] + tables?: CanonicalTableInput[] + } + | undefined + const inputFiles = [ + ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), + ...(inputs?.files ?? []), + ] + const inputDirectories = inputs?.directories ?? [] + const inputTables = [ + ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), + ...(inputs?.tables ?? []), + ] + + if (inputFiles?.length || inputTables?.length || inputDirectories.length) { + const resolved = await resolveInputFiles( + context.workspaceId, + inputFiles, + inputTables, + inputDirectories + ) + if (resolved.length > 0) { + const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] + enrichedParams._sandboxFiles = [...existing, ...resolved] } - | undefined - const inputFiles = [ - ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), - ...(inputs?.files ?? []), - ] - const inputDirectories = inputs?.directories ?? [] - const inputTables = [ - ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), - ...(inputs?.tables ?? []), - ] - - if (inputFiles?.length || inputTables?.length || inputDirectories.length) { - const resolved = await resolveInputFiles( - context.workspaceId, - inputFiles, - inputTables, - inputDirectories - ) - if (resolved.length > 0) { - const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] - enrichedParams._sandboxFiles = [...existing, ...resolved] } } - } - enrichedParams._context = { - ...(typeof enrichedParams._context === 'object' && enrichedParams._context !== null - ? (enrichedParams._context as object) - : {}), - userId: context.userId, - workflowId: context.workflowId, - workspaceId: context.workspaceId, - chatId: context.chatId, - executionId: context.executionId, - runId: context.runId, - enforceCredentialAccess: true, - } + enrichedParams._context = { + userId: context.userId, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + chatId: context.chatId, + executionId: context.executionId, + runId: context.runId, + enforceCredentialAccess: true, + } - return context.resolvedSecretTraceRegistry - ? executeAppTool('function_execute', enrichedParams, { - resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, - }) - : executeAppTool('function_execute', enrichedParams) + return await executeAppTool('function_execute', enrichedParams, { + resolvedSecretTraceRegistry: mountedRegistry, + }) + } finally { + if (mountedRegistry) { + await importMountedProvenance(mountedRegistry, context.resolvedSecretTraceRegistry) + } + completePendingActivation?.() + } } diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index 097794dac06..b7d1559d44d 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -138,6 +138,7 @@ vi.mock('../access', () => ({ getDefaultWorkspaceId: vi.fn(), })) +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { applyCreateWorkflowOutputToContext } from '@/lib/copilot/request/tools/workflow-context' import { performUpdateWorkflow } from '@/lib/workflows/orchestration' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' @@ -716,6 +717,61 @@ describe('Copilot workflow execution billing attribution', () => { expect(JSON.stringify(result)).not.toContain('encrypted-secret') }) + it('fails concurrent tool-result projection closed until child provenance is imported', async () => { + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-1', + }) + const context: ExecutionContext = { + ...executionContext, + resolvedSecretTraceRegistry: registry, + } + let resolveExecution!: (value: unknown) => void + let markExecutionStarted!: () => void + const executionStarted = new Promise((resolve) => { + markExecutionStarted = resolve + }) + executeWorkflowMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveExecution = resolve + markExecutionStarted() + }) + ) + + const execution = executeRunWorkflow( + { workflowId: 'workflow-1', useMockPayload: true }, + context + ) + await executionStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) + ).not.toHaveProperty('output') + + resolveExecution({ + success: true, + output: { value: 'secret-value' }, + logs: [], + metadata: { executionId: 'new-execution-1' }, + executionState: { + resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) + ).toMatchObject({ output: { value: '{{API_KEY}}' } }) + }) + it('marks provenance incomplete when child execution returns no trusted state', async () => { const registry = new ResolvedSecretTraceRegistry() const context: ExecutionContext = { diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 4a8823cd717..260bf73fbd2 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -95,10 +95,12 @@ async function executeCopilotWorkflowTarget(params: { params.workflow.workspaceId, childExecutionId ) + const trustedInitialResolvedSecretTraceProvenance = + params.context.resolvedSecretTraceRegistry?.exportProvenanceForValue(params.input) + const completePendingActivation = + params.context.resolvedSecretTraceRegistry?.beginPendingActivation() try { - const trustedInitialResolvedSecretTraceProvenance = - params.context.resolvedSecretTraceRegistry?.exportProvenanceForValue(params.input) const result = await executeWorkflow( params.workflow, generateRequestId(), @@ -139,6 +141,8 @@ async function executeCopilotWorkflowTarget(params: { await releaseExecutionSlot(childExecutionId) } throw error + } finally { + completePendingActivation?.() } } diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts new file mode 100644 index 00000000000..a900bf71556 --- /dev/null +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts @@ -0,0 +1,409 @@ +/** + * @vitest-environment node + */ +import { credential, environment, workspaceEnvironment } from '@sim/db/schema' +import { + dbChainMockFns, + encryptionMock, + encryptionMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({ + mockCheckWorkspaceAccess: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +import { + CopilotCodeSecretAccessError, + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, + materializeCopilotCodeSecrets, +} from '@/lib/copilot/tools/secret-mount-materializer.server' + +interface CredentialRow { + type: 'env_personal' | 'env_workspace' + envKey: string + envOwnerUserId: string | null + role: 'admin' | 'member' | null + status: 'active' | 'pending' | 'revoked' | null + updatedAt: Date + encryptedValue: string | null + encryptedValueBytes: number | null +} + +function queueSources(input: { + personal?: Record + personalOverLimit?: string[] + workspace?: Record + workspaceOverLimit?: string[] + credentials?: CredentialRow[] +}): void { + queueTableRows(environment, [ + { variables: input.personal ?? {}, overLimitNames: input.personalOverLimit ?? [] }, + ]) + queueTableRows(workspaceEnvironment, [ + { variables: input.workspace ?? {}, overLimitNames: input.workspaceOverLimit ?? [] }, + ]) + queueTableRows(credential, input.credentials ?? []) +} + +function credentialRow( + overrides: Partial & Pick +): CredentialRow { + return { + envOwnerUserId: null, + role: null, + status: null, + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + encryptedValue: null, + encryptedValueBytes: null, + ...overrides, + } +} + +describe('materializeCopilotCodeSecrets', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: `plain:${encryptedValue}`, + })) + }) + + it('mounts the actor own personal secret', async () => { + queueSources({ personal: { API_KEY: 'personal-cipher' } }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).resolves.toEqual({ + envVars: { API_KEY: 'plain:personal-cipher' }, + catalogEntries: [ + { name: 'API_KEY', plaintext: 'plain:personal-cipher', encryptedValue: 'personal-cipher' }, + ], + }) + }) + + it('lets a workspace admin mount workspace secrets with workspace precedence', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspace: { API_KEY: 'workspace-cipher' }, + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' }) + }) + + it('lets an active per-secret admin mount a workspace secret', async () => { + queueSources({ + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'admin', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' }) + }) + + it.each([ + ['member', 'active'], + ['admin', 'revoked'], + ['admin', 'pending'], + ] as const)('denies a workspace secret for a %s/%s credential grant', async (role, status) => { + queueSources({ + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [credentialRow({ type: 'env_workspace', envKey: 'API_KEY', role, status })], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toBeInstanceOf(CopilotCodeSecretAccessError) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('denies workspace secrets when the actor has zero credential grants', async () => { + queueSources({ workspace: { API_KEY: 'workspace-cipher' }, credentials: [] }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Copilot code cannot access the requested secret: API_KEY') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('lets an authorized personal value win over an unauthorized same-name workspace value', async () => { + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'member', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:personal-cipher' }) + }) + + it('lets an authorized personal value win over an unauthorized over-limit workspace value', async () => { + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspaceOverLimit: ['API_KEY'], + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'member', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:personal-cipher' }) + }) + + it('does not fall back when an authorized workspace value exceeds the encrypted byte limit', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspaceOverLimit: ['API_KEY'], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('does not fall back when the actor own personal value exceeds the encrypted byte limit', async () => { + queueSources({ + personalOverLimit: ['API_KEY'], + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'API_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValue: 'shared-cipher', + encryptedValueBytes: 13, + }), + ], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('mounts another owner personal secret only for an active per-secret admin', async () => { + queueSources({ + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'SHARED_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValue: 'shared-cipher', + encryptedValueBytes: 13, + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['SHARED_KEY'], + }) + + expect(result.envVars).toEqual({ SHARED_KEY: 'plain:shared-cipher' }) + }) + + it('uses the current encrypted value on every call so rotation is observed', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ workspace: { API_KEY: 'rotated-cipher' } }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:rotated-cipher' }) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledWith('rotated-cipher') + }) + + it('fails atomically for missing or deleted names before decrypting authorized values', async () => { + queueSources({ personal: { ALLOWED: 'allowed-cipher' } }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['ALLOWED', 'DELETED'], + }) + ).rejects.toThrow('Copilot code cannot access the requested secret: DELETED') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('fails the whole call when decryption fails', async () => { + queueSources({ personal: { API_KEY: 'broken-cipher' } }) + encryptionMockFns.mockDecryptSecret.mockRejectedValue(new Error('decrypt failed')) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('One or more requested secrets could not be decrypted') + }) + + it('fails the whole call when mounted plaintext exceeds the byte budget', async () => { + queueSources({ personal: { API_KEY: 'large-cipher' } }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'x'.repeat(64 * 1024 + 1) }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + }) + + it('fails the whole call when an authorized shared personal ciphertext exceeds the byte limit', async () => { + queueSources({ + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'API_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValueBytes: 512 * 1024 + 1, + }), + ], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('rejects over-limit requests before access checks, database reads, or decryption', async () => { + const requestedNames = Array.from( + { length: MAX_SECRET_MOUNT_NAMES + 1 }, + (_, index) => `SECRET_${index}` + ) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames, + }) + ).rejects.toThrow(`at most ${MAX_SECRET_MOUNT_NAMES} secrets`) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('rejects overlong names before access checks, database reads, or decryption', async () => { + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['S'.repeat(MAX_SECRET_MOUNT_NAME_LENGTH + 1)], + }) + ).rejects.toThrow(`at most ${MAX_SECRET_MOUNT_NAME_LENGTH} characters`) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts new file mode 100644 index 00000000000..064090c705d --- /dev/null +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts @@ -0,0 +1,311 @@ +import { db } from '@sim/db' +import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema' +import { and, desc, eq, inArray, or, sql } from 'drizzle-orm' +import type { AnyPgColumn } from 'drizzle-orm/pg-core' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' +import { decryptSecret } from '@/lib/core/security/encryption' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import type { ResolvedSecretTraceCatalogEntry } from '@/executor/utils/resolved-secret-trace-registry' + +export { MAX_SECRET_MOUNT_NAME_LENGTH, MAX_SECRET_MOUNT_NAMES } + +const MAX_SECRET_MOUNT_ENCRYPTED_BYTES = 512 * 1024 +const MAX_SECRET_MOUNT_PLAINTEXT_BYTES = 64 * 1024 +const MAX_SECRET_MOUNT_TOTAL_PLAINTEXT_BYTES = 256 * 1024 + +interface CredentialAccessRow { + type: 'env_personal' | 'env_workspace' + envKey: string + envOwnerUserId: string | null + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + updatedAt: Date + encryptedValue: string | null + encryptedValueBytes: number | null +} + +interface AuthorizedEncryptedSecret { + name: string + encryptedValue: string +} + +export interface MaterializedCopilotCodeSecrets { + envVars: Record + catalogEntries: ResolvedSecretTraceCatalogEntry[] +} + +export class CopilotCodeSecretAccessError extends Error { + constructor(message: string) { + super(message) + this.name = 'CopilotCodeSecretAccessError' + } +} + +function normalizeRequestedNames(names: readonly string[]): string[] { + const normalized: string[] = [] + const seen = new Set() + for (const name of names) { + if (name.length === 0 || seen.has(name)) continue + if (name.length > MAX_SECRET_MOUNT_NAME_LENGTH) { + throw new CopilotCodeSecretAccessError( + `Copilot secret names may be at most ${MAX_SECRET_MOUNT_NAME_LENGTH} characters` + ) + } + seen.add(name) + normalized.push(name) + } + if (normalized.length > MAX_SECRET_MOUNT_NAMES) { + throw new CopilotCodeSecretAccessError( + `Copilot code may request at most ${MAX_SECRET_MOUNT_NAMES} secrets per call` + ) + } + return normalized +} + +function encryptedVariables(row: { variables: unknown } | undefined): Record { + if (!row?.variables || typeof row.variables !== 'object' || Array.isArray(row.variables)) + return {} + const result: Record = {} + for (const [name, value] of Object.entries(row.variables)) { + if (typeof value === 'string') result[name] = value + } + return result +} + +function requestedVariables(column: AnyPgColumn, names: readonly string[]) { + const keys = sql.join( + names.map((name) => sql`${name}`), + sql`, ` + ) + return sql>`coalesce( + ( + select jsonb_object_agg(entry.key, entry.value) + from jsonb_each_text(coalesce(${column}, '{}'::jsonb)) as entry(key, value) + where entry.key in (${keys}) + and octet_length(entry.value) <= ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + ), + '{}'::jsonb + )`.as('variables') +} + +function requestedOverLimitNames(column: AnyPgColumn, names: readonly string[]) { + const keys = sql.join( + names.map((name) => sql`${name}`), + sql`, ` + ) + return sql`coalesce( + ( + select jsonb_agg(entry.key order by entry.key) + from jsonb_each_text(coalesce(${column}, '{}'::jsonb)) as entry(key, value) + where entry.key in (${keys}) + and octet_length(entry.value) > ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + ), + '[]'::jsonb + )`.as('over_limit_names') +} + +function overLimitNames(row: { overLimitNames?: unknown } | undefined): Set { + if (!Array.isArray(row?.overLimitNames)) return new Set() + return new Set(row.overLimitNames.filter((name): name is string => typeof name === 'string')) +} + +function activeAdmin(row: CredentialAccessRow): boolean { + return row.role === 'admin' && row.status === 'active' +} + +function unavailableError(names: readonly string[]): CopilotCodeSecretAccessError { + return new CopilotCodeSecretAccessError( + `Copilot code cannot access the requested secret${names.length === 1 ? '' : 's'}: ${names.join(', ')}` + ) +} + +/** + * Resolves exact Secrets-tab values for arbitrary Copilot code after rechecking current authority. + * No plaintext is produced until every requested name has an authorized source. + */ +export async function materializeCopilotCodeSecrets(params: { + actorUserId: string + workspaceId: string + requestedNames: readonly string[] +}): Promise { + const requestedNames = normalizeRequestedNames(params.requestedNames) + if (requestedNames.length === 0) return { envVars: {}, catalogEntries: [] } + + const access = await checkWorkspaceAccess(params.workspaceId, params.actorUserId) + if (!access.exists || !access.canWrite) { + throw new CopilotCodeSecretAccessError( + 'Write access is required to mount secrets into Copilot code' + ) + } + + const [personalRows, workspaceRows, credentialRows] = await Promise.all([ + db + .select({ + variables: requestedVariables(environment.variables, requestedNames), + overLimitNames: requestedOverLimitNames(environment.variables, requestedNames), + }) + .from(environment) + .where(eq(environment.userId, params.actorUserId)) + .limit(1), + db + .select({ + variables: requestedVariables(workspaceEnvironment.variables, requestedNames), + overLimitNames: requestedOverLimitNames(workspaceEnvironment.variables, requestedNames), + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, params.workspaceId)) + .limit(1), + db + .selectDistinctOn([credential.type, credential.envKey], { + type: credential.type, + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + role: credentialMember.role, + status: credentialMember.status, + updatedAt: credential.updatedAt, + encryptedValue: sql`case + when octet_length(${environment.variables} ->> ${credential.envKey}) <= ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + then ${environment.variables} ->> ${credential.envKey} + else null + end`.as('encrypted_value'), + encryptedValueBytes: sql< + number | null + >`octet_length(${environment.variables} ->> ${credential.envKey})`.as( + 'encrypted_value_bytes' + ), + }) + .from(credential) + .innerJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, params.actorUserId) + ) + ) + .leftJoin(environment, eq(environment.userId, credential.envOwnerUserId)) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + inArray(credential.type, ['env_workspace', 'env_personal']), + inArray(credential.envKey, requestedNames), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active'), + or( + eq(credential.type, 'env_workspace'), + sql`coalesce(${environment.variables}, '{}'::jsonb) ? ${credential.envKey}` + ) + ) + ) + .orderBy(credential.type, credential.envKey, desc(credential.updatedAt)) + .limit(MAX_SECRET_MOUNT_NAMES * 2), + ]) + + const ownPersonalEncrypted = encryptedVariables(personalRows[0]) + const workspaceEncrypted = encryptedVariables(workspaceRows[0]) + const ownPersonalOverLimit = overLimitNames(personalRows[0]) + const workspaceOverLimit = overLimitNames(workspaceRows[0]) + const envCredentialRows = credentialRows.filter( + (row): row is CredentialAccessRow => + (row.type === 'env_personal' || row.type === 'env_workspace') && + typeof row.envKey === 'string' + ) + const authorizedSharedPersonalRows = envCredentialRows.filter( + (row) => + row.type === 'env_personal' && + row.envOwnerUserId !== null && + row.envOwnerUserId !== params.actorUserId && + activeAdmin(row) + ) + + const authorizedSources: AuthorizedEncryptedSecret[] = [] + const unavailable: string[] = [] + const overLimit: string[] = [] + for (const name of requestedNames) { + const workspaceValue = workspaceEncrypted[name] + const workspaceExists = workspaceValue !== undefined || workspaceOverLimit.has(name) + const workspaceAuthorized = + workspaceExists && + (access.canAdmin || + envCredentialRows.some( + (row) => row.type === 'env_workspace' && row.envKey === name && activeAdmin(row) + )) + + if (workspaceAuthorized) { + if (workspaceValue === undefined) { + overLimit.push(name) + continue + } + authorizedSources.push({ name, encryptedValue: workspaceValue }) + continue + } + + const ownPersonalValue = ownPersonalEncrypted[name] + if (ownPersonalOverLimit.has(name)) { + overLimit.push(name) + continue + } + if (ownPersonalValue !== undefined) { + authorizedSources.push({ name, encryptedValue: ownPersonalValue }) + continue + } + + const sharedPersonal = authorizedSharedPersonalRows + .filter((row) => row.envKey === name) + .sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime()) + .at(0) + if ( + sharedPersonal && + sharedPersonal.encryptedValueBytes !== null && + sharedPersonal.encryptedValueBytes > MAX_SECRET_MOUNT_ENCRYPTED_BYTES + ) { + overLimit.push(name) + continue + } + const sharedPersonalValue = sharedPersonal?.encryptedValue ?? undefined + if (sharedPersonalValue !== undefined) { + authorizedSources.push({ name, encryptedValue: sharedPersonalValue }) + continue + } + + unavailable.push(name) + } + + if (overLimit.length > 0) { + throw new CopilotCodeSecretAccessError('Requested secrets exceed the Copilot mount size limit') + } + if (unavailable.length > 0) throw unavailableError(unavailable) + + let decryptedEntries: Array<{ name: string; plaintext: string; encryptedValue: string }> + try { + decryptedEntries = await Promise.all( + authorizedSources.map(async ({ name, encryptedValue }) => { + const { decrypted } = await decryptSecret(encryptedValue) + return { name, plaintext: decrypted, encryptedValue } + }) + ) + } catch { + throw new CopilotCodeSecretAccessError('One or more requested secrets could not be decrypted') + } + + let totalPlaintextBytes = 0 + for (const entry of decryptedEntries) { + const plaintextBytes = Buffer.byteLength(entry.plaintext, 'utf8') + totalPlaintextBytes += plaintextBytes + if ( + plaintextBytes > MAX_SECRET_MOUNT_PLAINTEXT_BYTES || + totalPlaintextBytes > MAX_SECRET_MOUNT_TOTAL_PLAINTEXT_BYTES + ) { + throw new CopilotCodeSecretAccessError( + 'Requested secrets exceed the Copilot mount size limit' + ) + } + } + + return { + envVars: Object.fromEntries(decryptedEntries.map((entry) => [entry.name, entry.plaintext])), + catalogEntries: decryptedEntries, + } +} diff --git a/apps/sim/lib/copilot/tools/secret-mount.test.ts b/apps/sim/lib/copilot/tools/secret-mount.test.ts new file mode 100644 index 00000000000..511e3f5aaae --- /dev/null +++ b/apps/sim/lib/copilot/tools/secret-mount.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { FunctionExecute, Read, RunCode } from '@/lib/copilot/generated/tool-catalog-v1' +import { + extractCodeSecretNames, + getToolSecretMountNames, + toolHasSecretMountCapability, +} from '@/lib/copilot/tools/secret-mount' + +describe('Copilot code secret declarations', () => { + it.each(['javascript', 'python'])( + 'matches trimmed and embedded references for %s', + (language) => { + expect( + extractCodeSecretNames( + 'const first = "prefix-{{ API_KEY }}"\nreturn "{{TOKEN}}/{{API_KEY}}"', + language + ) + ).toEqual(['API_KEY', 'TOKEN']) + } + ) + + it('matches only runtime-valid shell identifiers without trimming', () => { + expect( + extractCodeSecretNames( + 'echo {{API_KEY}} {{ API_KEY }} {{9INVALID}} {{WITH-DASH}} {{_TOKEN}}', + 'shell' + ) + ).toEqual(['API_KEY', '_TOKEN']) + }) + + it('ignores direct environment access, shell variables, literals, and malformed references', () => { + expect( + extractCodeSecretNames( + 'return environmentVariables.API_KEY + "$TOKEN" + "literal" + "{{}}" + "{{MISSING"', + 'javascript' + ) + ).toEqual([]) + }) + + it('uses the generated capability as the sole tool classifier', () => { + expect(toolHasSecretMountCapability(FunctionExecute.id)).toBe(true) + expect(toolHasSecretMountCapability(RunCode.id)).toBe(true) + expect(toolHasSecretMountCapability(Read.id)).toBe(false) + expect(getToolSecretMountNames(Read.id, { code: 'return {{SECRET}}' })).toEqual([]) + expect(getToolSecretMountNames(RunCode.id, { code: 'return {{SECRET}}' })).toEqual(['SECRET']) + }) +}) diff --git a/apps/sim/lib/copilot/tools/secret-mount.ts b/apps/sim/lib/copilot/tools/secret-mount.ts new file mode 100644 index 00000000000..3a376eba664 --- /dev/null +++ b/apps/sim/lib/copilot/tools/secret-mount.ts @@ -0,0 +1,20 @@ +import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' +import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' + +export { extractCodeSecretNames } from '@/executor/utils/code-secret-references' + +export const SECRET_MOUNT_CAPABILITY = 'secret_mount' as const + +export function toolHasSecretMountCapability(toolName: string): boolean { + const capabilities = TOOL_CATALOG[toolName]?.capabilities + return Array.isArray(capabilities) && capabilities.includes(SECRET_MOUNT_CAPABILITY) +} + +/** Returns the explicit secret names requested by a catalog-declared secret-mounting tool call. */ +export function getToolSecretMountNames( + toolName: string, + params: Record | undefined +): string[] { + if (!toolHasSecretMountCapability(toolName) || !params) return [] + return extractCodeSecretNames(params.code, params.language) +} diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts new file mode 100644 index 00000000000..f8d84b3fe93 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts @@ -0,0 +1,16 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { computeBlockLevelInputs } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' +import { MothershipBlock } from '@/blocks/blocks/mothership' + +describe('get blocks metadata', () => { + it('omits server-only Mothership policy inputs from block metadata definitions', () => { + const definitions = computeBlockLevelInputs(MothershipBlock) + + expect(definitions).not.toHaveProperty('secretScope') + expect(definitions).not.toHaveProperty('mountedSecrets') + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 6dbb214de80..5a7c1f2d3a9 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -180,7 +180,9 @@ export const getBlocksMetadataServerTool: BaseServerTool< // `workflow_executor`; the agent never configures a workflowId/inputMapping. // Present it as self-contained: its visible input fields + curated outputs, // no tools/operations. - const visibleSubBlocks = (blockConfig.subBlocks || []).filter((sb) => !sb.hidden) + const visibleSubBlocks = (blockConfig.subBlocks || []).filter( + (sb) => !sb.hidden && !sb.hideFromCopilot + ) const outputs = blockConfig.outputs ? Object.fromEntries( Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) @@ -273,11 +275,13 @@ export const getBlocksMetadataServerTool: BaseServerTool< }) } - const blockInputs = computeBlockLevelInputs(blockConfig) + const hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) + const blockInputs = computeBlockLevelInputs(blockConfig, hiddenParamKeys) const { commonParameters, operationParameters } = splitParametersByOperation( Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks.filter( - (sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + (sb) => + !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' ) : [], blockInputs @@ -297,7 +301,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< : {} const filteredToolParams: Record = {} for (const [k, v] of Object.entries(toolParams)) { - if (!(k in blockInputs)) filteredToolParams[k] = v + if (!(k in blockInputs) && !hiddenParamKeys.has(k)) filteredToolParams[k] = v } operations[opId] = { toolId: resolvedToolId, @@ -968,10 +972,25 @@ function splitParametersByOperation( return { commonParameters, operationParameters } } -function computeBlockLevelInputs(blockConfig: BlockConfig): Record { +function getCopilotHiddenParamKeys(blockConfig: BlockConfig): Set { + const hiddenParamKeys = new Set() + for (const subBlock of blockConfig.subBlocks ?? []) { + if (!subBlock.hideFromCopilot) continue + if (subBlock.id) hiddenParamKeys.add(subBlock.id) + if (subBlock.canonicalParamId) hiddenParamKeys.add(subBlock.canonicalParamId) + } + return hiddenParamKeys +} + +export function computeBlockLevelInputs( + blockConfig: BlockConfig, + hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) +): Record { const inputs = blockConfig.inputs || {} const subBlocks: any[] = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced') + ? blockConfig.subBlocks.filter( + (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + ) : [] const byParamKey: Record = {} @@ -988,6 +1007,7 @@ function computeBlockLevelInputs(blockConfig: BlockConfig): Record const blockInputs: Record = {} for (const key of Object.keys(inputs)) { + if (hiddenParamKeys.has(key)) continue const sbs = byParamKey[key] || [] const isOperationGated = sbs.some((sb) => { const cond = normalizeCondition(sb.condition) @@ -1006,7 +1026,9 @@ function computeOperationLevelInputs( ): Record> { const inputs = blockConfig.inputs || {} const subBlocks = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced') + ? blockConfig.subBlocks.filter( + (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + ) : [] const opInputs: Record> = {} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 295ba44db98..8ae05a41465 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -150,6 +150,17 @@ const genericWebhookBlockConfig = { ], } +const mothershipBlockConfig = { + type: 'mothership', + name: 'Sim Chat', + outputs: {}, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { id: 'mountedSecrets', type: 'dropdown', hideFromCopilot: true }, + ], +} + // Block whose tool selector throws — should fall back to scanning access tools (video_falai). const throwSelectorBlockConfig = { type: 'throw_selector_block', @@ -204,6 +215,7 @@ const blockConfigsByType: Record = { throw_gate_block: throwGateBlockConfig, throw_selector_block: throwSelectorBlockConfig, generic_webhook: genericWebhookBlockConfig, + mothership: mothershipBlockConfig, } vi.mock('@/blocks/registry', () => ({ @@ -358,6 +370,17 @@ describe('validateInputsForBlock', () => { expect(result.errors[0]?.error).toContain('read-only') }) + it('rejects server-only Sim Chat secret-mount policy inputs', () => { + const result = validateInputsForBlock( + 'mothership', + { prompt: 'Keep this', secretScope: 'all', mountedSecrets: ['API_KEY'] }, + 'chat-1' + ) + + expect(result.validInputs).toEqual({ prompt: 'Keep this' }) + expect(result.errors.map((error) => error.field)).toEqual(['secretScope', 'mountedSecrets']) + }) + it('accepts known agent model ids', () => { const result = validateInputsForBlock('agent', { model: 'claude-sonnet-4-6' }, 'agent-1') diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 36bc722a0f8..48d44f21dbc 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -128,6 +128,17 @@ export function validateInputsForBlock( continue } + if (subBlockConfig.hideFromCopilot === true) { + errors.push({ + blockId, + blockType, + field: key, + value, + error: `Field "${key}" on block type "${blockType}" is server-managed and cannot be set by Copilot`, + }) + continue + } + // Note: We do NOT check subBlockConfig.condition here. // Conditions are for UI display logic (show/hide fields in the editor). // For API/Copilot, any valid field in the block schema should be accepted. diff --git a/apps/sim/lib/copilot/tools/workflow-tools.ts b/apps/sim/lib/copilot/tools/workflow-tools.ts index fc750614dfb..ff346c71118 100644 --- a/apps/sim/lib/copilot/tools/workflow-tools.ts +++ b/apps/sim/lib/copilot/tools/workflow-tools.ts @@ -1,3 +1,9 @@ +import { isPlainRecord } from '@sim/utils/object' +import { + ASYNC_TOOL_CONFIRMATION_STATUS, + type AsyncConfirmationStatus, +} from '@/lib/copilot/async-runs/lifecycle' + const WORKFLOW_TOOL_NAMES = [ 'run_workflow', 'run_workflow_until_block', @@ -10,3 +16,57 @@ const WORKFLOW_TOOL_NAME_SET = new Set(WORKFLOW_TOOL_NAMES) export function isWorkflowToolName(name: string): boolean { return WORKFLOW_TOOL_NAME_SET.has(name) } + +/** Resolves the workflow target from immutable tool arguments, then the owning Copilot run. */ +export function resolveWorkflowToolTargetId( + args: unknown, + runWorkflowId?: string | null +): string | undefined { + if (isPlainRecord(args) && typeof args.workflowId === 'string' && args.workflowId.length > 0) { + return args.workflowId + } + return typeof runWorkflowId === 'string' && runWorkflowId.length > 0 ? runWorkflowId : undefined +} + +export function getWorkflowToolCompletionMessage(status: AsyncConfirmationStatus): string { + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) { + return 'Workflow execution completed.' + } + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) { + return 'Workflow execution was cancelled.' + } + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + return 'Workflow execution is continuing in the background.' + } + return 'Workflow execution failed.' +} + +export function getWorkflowToolConfirmationStatus( + status: 'completed' | 'failed' | 'cancelled' +): AsyncConfirmationStatus { + if (status === 'completed') return ASYNC_TOOL_CONFIRMATION_STATUS.success + if (status === 'cancelled') return ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + return ASYNC_TOOL_CONFIRMATION_STATUS.error +} + +export function createStructuralWorkflowToolCompletionData( + status: AsyncConfirmationStatus, + workflowId?: string, + executionId?: string +): Record { + const data: Record = {} + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) data.success = true + if ( + status === ASYNC_TOOL_CONFIRMATION_STATUS.error || + status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + ) { + data.success = false + } + if (workflowId) data.workflowId = workflowId + if (executionId) data.executionId = executionId + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) { + data.reason = 'user_cancelled' + data.cancelledByUser = true + } + return data +} diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index daf3c3c8086..4394b746e0b 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -217,6 +217,44 @@ describe('hosted-key VFS metadata', () => { expect(schema.inputs.apiKey).toBeDefined() expect(schema.toolAuth.search.mode).toBe('hosted_or_byok') }) + + it('omits server-only lifecycle inputs from block schemas', () => { + const block = { + type: 'mothership', + name: 'Sim Chat', + description: 'Talk to Sim', + category: 'blocks', + bgColor: '#000000', + icon: () => null, + subBlocks: [ + { id: 'prompt', title: 'Prompt', type: 'long-input' }, + { + id: 'secretScope', + title: 'Secret access', + type: 'dropdown', + hideFromCopilot: true, + }, + { + id: 'mountedSecrets', + title: 'Secrets', + type: 'dropdown', + hideFromCopilot: true, + }, + ], + tools: { access: [] }, + inputs: { + prompt: { type: 'string' }, + secretScope: { type: 'string' }, + mountedSecrets: { type: 'json' }, + }, + outputs: {}, + } as unknown as BlockConfig + + const schema = JSON.parse(serializeBlockSchema(block)) + + expect(schema.subBlocks.map((subBlock: { id: string }) => subBlock.id)).toEqual(['prompt']) + expect(schema.inputs).toEqual({ prompt: { type: 'string' } }) + }) }) describe('serializeKBMeta', () => { diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 0972b7a0d1f..5dd5959a86e 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -573,12 +573,14 @@ export function serializeBlockSchema( const customBlock = isCustomBlockType(block.type) const hosted = options?.hosted ?? isHosted const visibleSubBlocks = block.subBlocks.filter( - (sb) => !isSubBlockHidden(sb, { hosted }) && !(customBlock && sb.hidden) + (sb) => !sb.hideFromCopilot && !isSubBlockHidden(sb, { hosted }) && !(customBlock && sb.hidden) ) const visibleIds = new Set(visibleSubBlocks.map((sb) => sb.id)) const hiddenIds = new Set( block.subBlocks - .filter((sb) => isSubBlockHidden(sb, { hosted }) || (customBlock && sb.hidden)) + .filter( + (sb) => sb.hideFromCopilot || isSubBlockHidden(sb, { hosted }) || (customBlock && sb.hidden) + ) .map((sb) => sb.id) .filter((id) => !visibleIds.has(id)) ) diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts index 12b852165f1..9a1ee04aefa 100644 --- a/apps/sim/lib/core/async-jobs/types.ts +++ b/apps/sim/lib/core/async-jobs/types.ts @@ -39,6 +39,8 @@ export interface AsyncExecutionCorrelation { requestId: string source: AsyncExecutionCorrelationSource workflowId: string + /** Server-validated binding for a browser-routed Copilot workflow tool execution. */ + copilotToolCallId?: string triggerType?: string webhookId?: string scheduleId?: string diff --git a/apps/sim/lib/credentials/environment.test.ts b/apps/sim/lib/credentials/environment.test.ts index 64c89d058d8..349a94d5db0 100644 --- a/apps/sim/lib/credentials/environment.test.ts +++ b/apps/sim/lib/credentials/environment.test.ts @@ -15,10 +15,93 @@ vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({ })) import { + getPersonalEnvKeyRawAccess, getWorkspaceEnvKeyAdminAccess, syncPersonalEnvCredentialsForUser, } from '@/lib/credentials/environment' +describe('getPersonalEnvKeyRawAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns own values without querying credential grants', async () => { + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { OWN_KEY: 'u-1' }, + }) + + expect([...result.ownedKeys]).toEqual(['OWN_KEY']) + expect(result.adminKeys.size).toBe(0) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('allows own values and only active admin grants for other personal values', async () => { + queueTableRows(credential, [ + { + envKey: 'SHARED_ADMIN', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + }, + { + envKey: 'SHARED_MEMBER', + envOwnerUserId: 'owner-3', + role: 'member', + status: 'active', + }, + { + envKey: 'REVOKED_ADMIN', + envOwnerUserId: 'owner-4', + role: 'admin', + status: 'revoked', + }, + ]) + + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { + OWN_KEY: 'u-1', + SHARED_ADMIN: 'owner-2', + SHARED_MEMBER: 'owner-3', + REVOKED_ADMIN: 'owner-4', + }, + }) + + expect([...result.ownedKeys]).toEqual(['OWN_KEY']) + expect([...result.adminKeys]).toEqual(['SHARED_ADMIN']) + }) + + it('requires the admin grant to belong to the exact effective secret owner', async () => { + queueTableRows(credential, [ + { + envKey: 'COLLISION', + envOwnerUserId: 'owner-a', + role: 'admin', + status: 'active', + }, + { + envKey: 'COLLISION', + envOwnerUserId: 'owner-b', + role: 'member', + status: 'active', + }, + ]) + + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { COLLISION: 'owner-b' }, + }) + + expect(result.ownedKeys.size).toBe(0) + expect(result.adminKeys.size).toBe(0) + }) +}) + describe('getWorkspaceEnvKeyAdminAccess', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index da4f743cbab..e70be39ebff 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -107,6 +107,67 @@ export interface WorkspaceEnvKeyAdminAccess { knownKeys: Set } +export interface PersonalEnvKeyRawAccess { + /** Keys stored in the caller's own personal Secrets catalog. */ + ownedKeys: Set + /** Keys owned by someone else for which the caller is an active credential admin. */ + adminKeys: Set +} + +/** Resolves which personal secret values a workspace viewer may read as plaintext. */ +export async function getPersonalEnvKeyRawAccess(params: { + workspaceId: string + personalOwners: Record + userId: string +}): Promise { + const keys = Object.keys(params.personalOwners) + if (keys.length === 0) return { ownedKeys: new Set(), adminKeys: new Set() } + + const ownedKeys = new Set( + keys.filter((envKey) => params.personalOwners[envKey] === params.userId) + ) + const sharedKeys = keys.filter((envKey) => !ownedKeys.has(envKey)) + if (sharedKeys.length === 0) return { ownedKeys, adminKeys: new Set() } + + const credentialRows = await db + .select({ + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + role: credentialMember.role, + status: credentialMember.status, + }) + .from(credential) + .leftJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, params.userId) + ) + ) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'env_personal'), + inArray(credential.envKey, sharedKeys) + ) + ) + + const adminKeys = new Set() + for (const row of credentialRows) { + if ( + row.envKey && + row.envOwnerUserId === params.personalOwners[row.envKey] && + row.envOwnerUserId !== params.userId && + row.role === 'admin' && + row.status === 'active' + ) { + adminKeys.add(row.envKey) + } + } + + return { ownedKeys, adminKeys } +} + /** * For a set of workspace env keys, resolves which the caller may administer * (active `credential_member` with role `admin`) and which already have an diff --git a/apps/sim/lib/credentials/secret-mount-options.test.ts b/apps/sim/lib/credentials/secret-mount-options.test.ts new file mode 100644 index 00000000000..ae688715dbc --- /dev/null +++ b/apps/sim/lib/credentials/secret-mount-options.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import type { WorkspaceCredential } from '@/lib/api/contracts' +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' + +function credential( + overrides: Partial & Pick +): WorkspaceCredential { + return { + workspaceId: 'workspace-1', + displayName: overrides.id, + description: null, + providerId: null, + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides, + } +} + +describe('selectRawMountableSecretNames', () => { + it('keeps only admin environment credentials and returns unique sorted names', () => { + const credentials = [ + credential({ id: 'workspace-z', type: 'env_workspace', envKey: 'ZETA', role: 'admin' }), + credential({ id: 'personal-a', type: 'env_personal', envKey: 'ALPHA', role: 'admin' }), + credential({ id: 'duplicate-a', type: 'env_workspace', envKey: 'ALPHA', role: 'admin' }), + credential({ id: 'member', type: 'env_workspace', envKey: 'MEMBER', role: 'member' }), + credential({ id: 'oauth', type: 'oauth', envKey: 'OAUTH', role: 'admin' }), + credential({ id: 'missing-key', type: 'env_personal', envKey: null, role: 'admin' }), + ] + + expect(selectRawMountableSecretNames(credentials)).toEqual(['ALPHA', 'ZETA']) + }) +}) diff --git a/apps/sim/lib/credentials/secret-mount-options.ts b/apps/sim/lib/credentials/secret-mount-options.ts new file mode 100644 index 00000000000..d159601ac69 --- /dev/null +++ b/apps/sim/lib/credentials/secret-mount-options.ts @@ -0,0 +1,22 @@ +import type { WorkspaceCredential } from '@/lib/api/contracts' + +/** + * Returns the secret names the current credential-list actor may mount as plaintext. + * The credentials API has already derived workspace-admin and per-credential roles; + * this selector deliberately keeps only environment credentials with effective admin access. + */ +export function selectRawMountableSecretNames(credentials: WorkspaceCredential[]): string[] { + const names = new Set() + + for (const credential of credentials) { + if ( + (credential.type === 'env_workspace' || credential.type === 'env_personal') && + credential.role === 'admin' && + credential.envKey + ) { + names.add(credential.envKey) + } + } + + return [...names].sort() +} diff --git a/apps/sim/lib/environment/utils.test.ts b/apps/sim/lib/environment/utils.test.ts index feb5c95bef0..91f0bdf6b3b 100644 --- a/apps/sim/lib/environment/utils.test.ts +++ b/apps/sim/lib/environment/utils.test.ts @@ -1,16 +1,27 @@ /** * @vitest-environment node */ -import { dbChainMockFns, encryptionMock, encryptionMockFns, resetDbChainMock } from '@sim/testing' +import { environment, workspaceEnvironment } from '@sim/db/schema' +import { + dbChainMockFns, + encryptionMock, + encryptionMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreateWorkspaceEnvCredentials, + mockCheckWorkspaceAccess, + mockGetAccessibleEnvCredentials, mockGetUserEntityPermissions, mockGetWorkspaceEnvKeyAdminAccess, mockRecordAudit, } = vi.hoisted(() => ({ mockCreateWorkspaceEnvCredentials: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockGetAccessibleEnvCredentials: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), mockRecordAudit: vi.fn(), @@ -27,23 +38,87 @@ vi.mock('@sim/audit', () => ({ })) vi.mock('@/lib/credentials/environment', () => ({ createWorkspaceEnvCredentials: mockCreateWorkspaceEnvCredentials, - getAccessibleEnvCredentials: vi.fn(), + getAccessibleEnvCredentials: mockGetAccessibleEnvCredentials, getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, syncPersonalEnvCredentialsForUser: vi.fn(), })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: vi.fn(), + checkWorkspaceAccess: mockCheckWorkspaceAccess, getUserEntityPermissions: mockGetUserEntityPermissions, })) import { getEffectiveDecryptedEnv, getEffectiveEnvironmentSnapshot, + getPersonalAndWorkspaceEnv, invalidateEffectiveDecryptedEnvCache, upsertWorkspaceEnvVars, WorkspaceEnvAccessError, } from '@/lib/environment/utils' +describe('getPersonalAndWorkspaceEnv access filtering', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + mockGetAccessibleEnvCredentials.mockResolvedValue([]) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: `plain:${encryptedValue}`, + })) + }) + + it('filters every workspace secret when the caller has zero credential grants', async () => { + queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) + + const snapshot = await getPersonalAndWorkspaceEnv('user-1', 'workspace-1') + + expect(snapshot.personalDecrypted).toEqual({ PERSONAL_KEY: 'plain:personal-cipher' }) + expect(snapshot.workspaceDecrypted).toEqual({}) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledOnce() + }) + + it('preserves legacy workspace secrets without credential rows for workspace admins', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueTableRows(environment, [{ variables: {} }]) + queueTableRows(workspaceEnvironment, [{ variables: { LEGACY_KEY: 'legacy-cipher' } }]) + + const snapshot = await getPersonalAndWorkspaceEnv('admin-1', 'workspace-1') + + expect(snapshot.workspaceDecrypted).toEqual({ LEGACY_KEY: 'plain:legacy-cipher' }) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledOnce() + }) + + it('preserves shared-personal precedence when an accessible owner shares the same name', async () => { + mockGetAccessibleEnvCredentials.mockResolvedValue([ + { + type: 'env_personal', + envKey: 'SHARED_KEY', + envOwnerUserId: 'owner-2', + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + queueTableRows(environment, [{ variables: { SHARED_KEY: 'own-cipher' } }]) + queueTableRows(environment, [{ userId: 'owner-2', variables: { SHARED_KEY: 'shared-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: {} }]) + + const snapshot = await getPersonalAndWorkspaceEnv('user-1', 'workspace-1') + + expect(snapshot.personalDecrypted).toEqual({ SHARED_KEY: 'plain:shared-cipher' }) + expect(snapshot.personalOwners).toEqual({ SHARED_KEY: 'owner-2' }) + }) +}) + describe('upsertWorkspaceEnvVars', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 37089698158..65d9b89be0b 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -48,6 +48,7 @@ export interface EnvironmentResolutionSnapshot { workspaceEncrypted: Record personalDecrypted: Record workspaceDecrypted: Record + personalOwners: Record conflicts: string[] decryptionFailures: string[] } @@ -75,6 +76,7 @@ function cloneEnvironmentResolutionSnapshot( workspaceEncrypted: { ...snapshot.workspaceEncrypted }, personalDecrypted: { ...snapshot.personalDecrypted }, workspaceDecrypted: { ...snapshot.workspaceDecrypted }, + personalOwners: { ...snapshot.personalOwners }, conflicts: [...snapshot.conflicts], decryptionFailures: [...snapshot.decryptionFailures], } @@ -165,7 +167,7 @@ export async function getPersonalAndWorkspaceEnv( const ownPersonalEncrypted: Record = (personalRows[0]?.variables as any) || {} const allWorkspaceEncrypted: Record = (workspaceRows[0]?.variables as any) || {} - const hasCredentialFiltering = Boolean(workspaceId) && accessibleEnvCredentials.length > 0 + const hasCredentialFiltering = Boolean(workspaceId) const workspaceCredentialKeys = new Set( accessibleEnvCredentials.filter((row) => row.type === 'env_workspace').map((row) => row.envKey) ) @@ -205,6 +207,9 @@ export async function getPersonalAndWorkspaceEnv( let personalEncrypted: Record = ownPersonalEncrypted let workspaceEncrypted: Record = allWorkspaceEncrypted + const personalOwners: Record = Object.fromEntries( + Object.keys(ownPersonalEncrypted).map((envKey) => [envKey, userId]) + ) if (hasCredentialFiltering) { personalEncrypted = { ...ownPersonalEncrypted } @@ -213,14 +218,17 @@ export async function getPersonalAndWorkspaceEnv( const encryptedValue = ownerVariables?.[envKey] if (encryptedValue) { personalEncrypted[envKey] = encryptedValue + personalOwners[envKey] = ownerUserId } } - workspaceEncrypted = Object.fromEntries( - Object.entries(allWorkspaceEncrypted).filter(([envKey]) => - workspaceCredentialKeys.has(envKey) - ) - ) + workspaceEncrypted = workspaceCanAdmin + ? { ...allWorkspaceEncrypted } + : Object.fromEntries( + Object.entries(allWorkspaceEncrypted).filter(([envKey]) => + workspaceCredentialKeys.has(envKey) + ) + ) } const decryptionFailures: string[] = [] @@ -268,6 +276,7 @@ export async function getPersonalAndWorkspaceEnv( workspaceEncrypted, personalDecrypted, workspaceDecrypted, + personalOwners, conflicts, decryptionFailures, } diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index b4e79338534..eb1616e0869 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -92,7 +92,12 @@ vi.mock('@/lib/logs/execution/logging-factory', () => ({ models: {}, }), createEnvironmentObject: vi.fn(), - createTriggerObject: vi.fn(), + createTriggerObject: vi.fn((type: string, additionalData?: Record) => ({ + type, + source: type, + timestamp: '2026-01-01T00:00:00.000Z', + ...(additionalData ? { data: additionalData } : {}), + })), loadDeployedWorkflowStateForLogging: vi.fn(), loadWorkflowStateForExecution: loadWorkflowStateForExecutionMock, })) @@ -234,6 +239,40 @@ describe('LoggingSession start snapshots', () => { ) }) + it('persists only the server-validated execution correlation', async () => { + const session = new LoggingSession('workflow-1', 'execution-1', 'copilot', 'req-1') + const trustedCorrelation = { + executionId: 'execution-1', + requestId: 'req-1', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'trusted-tool-call', + } + session.setTrustedExecutionCorrelation(trustedCorrelation) + + await session.start({ + userId: 'user-1', + workspaceId: 'workspace-1', + triggerData: { + correlation: { + executionId: 'submitted-execution', + requestId: 'submitted-request', + source: 'workflow', + copilotToolCallId: 'submitted-tool-call', + }, + }, + }) + + expect(startWorkflowExecutionMock).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: expect.objectContaining({ + data: expect.objectContaining({ correlation: trustedCorrelation }), + }), + }) + ) + }) + it('does not create a log when hydrating a persisted execution for completion', async () => { const session = new LoggingSession('workflow-1', 'execution-existing', 'manual', 'req-existing') diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index 9703d5b57bd..bf96ef4022d 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -185,6 +185,7 @@ export class LoggingSession { private environment?: ExecutionEnvironment private workflowState?: WorkflowState private correlation?: NonNullable['correlation'] + private trustedExecutionCorrelation?: NonNullable['correlation'] private actorUserId: string | null = null private billingAttribution?: BillingAttributionSnapshot private isResume = false @@ -225,6 +226,13 @@ export class LoggingSession { this.resolvedSecretTraceRegistry = registry } + /** Adds server-validated lifecycle correlation without exposing it to executor metadata. */ + setTrustedExecutionCorrelation( + correlation: NonNullable['correlation']> + ): void { + this.trustedExecutionCorrelation = { ...correlation } + } + /** Adds the trusted execution-ref scope needed to rewrite offloaded trace content. */ setTraceLargeValueAccess(context: LargeValueStoreContext): void { this.traceLargeValueAccess = context @@ -618,8 +626,11 @@ export class LoggingSession { } try { - this.trigger = createTriggerObject(this.triggerType, triggerData) - this.correlation = triggerData?.correlation + const effectiveTriggerData = this.trustedExecutionCorrelation + ? { ...triggerData, correlation: this.trustedExecutionCorrelation } + : triggerData + this.trigger = createTriggerObject(this.triggerType, effectiveTriggerData) + this.correlation = effectiveTriggerData?.correlation this.environment = createEnvironmentObject( this.workflowId, this.executionId, @@ -1081,8 +1092,11 @@ export class LoggingSession { deploymentVersionId, workflowState, } = params - this.trigger = createTriggerObject(this.triggerType, triggerData) - this.correlation = triggerData?.correlation + const effectiveTriggerData = this.trustedExecutionCorrelation + ? { ...triggerData, correlation: this.trustedExecutionCorrelation } + : triggerData + this.trigger = createTriggerObject(this.triggerType, effectiveTriggerData) + this.correlation = effectiveTriggerData?.correlation this.environment = createEnvironmentObject( this.workflowId, this.executionId, diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.ts b/apps/sim/lib/logs/execution/trace-secret-projection.ts index ac94e18ea55..9164002569d 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.ts @@ -22,8 +22,8 @@ import type { IterationToolCall, ProviderTimingSegment } from '@/executor/types' import { containsResolvedSecret, createResolvedSecretMatcher, + projectResolvedSecretContent, type ResolvedSecretMatcher, - sanitizeResolvedSecretString, } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -96,11 +96,6 @@ interface TraversalState { ancestors: WeakSet } -interface SanitizationTraversalState extends TraversalState { - outputBytes: number - maxBytes: number -} - interface PlaintextInvariantContext { matcher: ResolvedSecretMatcher safeLargeValues: WeakSet @@ -385,87 +380,6 @@ function getLargeValueCandidate(value: unknown): LargeValueCandidate | undefined return value as LargeArrayManifest } -function sanitizeInlineValue( - value: unknown, - matcher: ResolvedSecretMatcher, - safeLargeValues: WeakSet, - state: SanitizationTraversalState, - depth = 0 -): unknown { - visitNode(state, depth) - if (typeof value === 'string') { - const sanitized = sanitizeResolvedSecretString( - value, - matcher, - state.maxBytes - state.outputBytes - ) - state.outputBytes += Buffer.byteLength(sanitized, 'utf8') - return sanitized - } - if (value === null || typeof value === 'number' || typeof value === 'boolean') { - const rendered = String(value) - if (!containsResolvedSecret(rendered, matcher)) return value - const sanitized = sanitizeResolvedSecretString( - rendered, - matcher, - state.maxBytes - state.outputBytes - ) - state.outputBytes += Buffer.byteLength(sanitized, 'utf8') - return sanitized - } - if (value === undefined) return value - if (typeof value !== 'object') { - throw new TraceSecretProjectionError('Unsupported trace content value') - } - const largeValue = getLargeValueCandidate(value) - if (largeValue) { - if (!safeLargeValues.has(value as object)) { - throw new TraceSecretProjectionError('Trace content contains an unverified large value') - } - return value - } - if (!Array.isArray(value) && !isPlainRecord(value)) { - throw new TraceSecretProjectionError('Unsupported trace content object') - } - - enterObject(value, state) - try { - if (Array.isArray(value)) { - assertArrayFitsTraversal(value, state) - const sanitized = new Array(value.length) - for (const [index, item] of arrayDataEntries(value)) { - sanitized[index] = sanitizeInlineValue(item, matcher, safeLargeValues, state, depth + 1) - } - return sanitized - } - - const prototype = Object.getPrototypeOf(value) - const sanitized = Object.create(prototype) as Record - const sanitizedKeys = new Set() - for (const [key, item] of enumerableDataEntries(value)) { - const sanitizedKey = sanitizeResolvedSecretString( - key, - matcher, - state.maxBytes - state.outputBytes - ) - state.outputBytes += Buffer.byteLength(sanitizedKey, 'utf8') - if (sanitizedKeys.has(sanitizedKey)) { - throw new TraceSecretProjectionError('Secret replacement caused an object-key collision') - } - sanitizedKeys.add(sanitizedKey) - Object.defineProperty(sanitized, sanitizedKey, { - value: sanitizeInlineValue(item, matcher, safeLargeValues, state, depth + 1), - enumerable: true, - configurable: true, - writable: true, - }) - } - return sanitized - } finally { - leaveObject(value, state) - } -} - function collectLargeValues( value: unknown, refs: object[], @@ -646,12 +560,13 @@ async function sanitizeMaterializedValue( withinRefWorker = false ): Promise { const withSafeRefs = await replaceLargeValues(value, context, path, withinRefWorker) - return sanitizeInlineValue(withSafeRefs, context.matcher, context.safeLargeValues, { - nodes: 0, - ancestors: new WeakSet(), - outputBytes: 0, - maxBytes, + const projection = projectResolvedSecretContent(withSafeRefs, context.matcher, maxBytes, { + isOpaqueSafeObject: (candidate) => context.safeLargeValues.has(candidate), }) + if (!projection.safe) { + throw new TraceSecretProjectionError('Trace content could not be sanitized') + } + return projection.value } async function storeSanitizedLargeValue( diff --git a/apps/sim/lib/mothership/inbox/executor.test.ts b/apps/sim/lib/mothership/inbox/executor.test.ts new file mode 100644 index 00000000000..f8231124a92 --- /dev/null +++ b/apps/sim/lib/mothership/inbox/executor.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckWorkspaceAccess, + mockGetUserEntityPermissions, + mockRunHeadlessCopilotLifecycle, + mockSendInboxResponse, +} = vi.hoisted(() => ({ + mockCheckWorkspaceAccess: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockRunHeadlessCopilotLifecycle: vi.fn(), + mockSendInboxResponse: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@/lib/auth/ban', () => ({ + getActivelyBannedUserIds: vi.fn().mockResolvedValue([]), + isEmailBlocked: vi.fn().mockResolvedValue(false), +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + resolveOrCreateChat: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/payload', () => ({ + buildIntegrationToolSchemas: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/lib/copilot/chat/persisted-message', () => ({ + buildPersistedAssistantMessage: vi.fn().mockReturnValue({ id: 'assistant-message' }), + buildPersistedUserMessage: vi.fn().mockReturnValue({ id: 'user-message' }), +})) + +vi.mock('@/lib/copilot/chat/workspace-context', () => ({ + generateWorkspaceContext: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: vi.fn() }, +})) + +vi.mock('@/lib/copilot/entitlements', () => ({ + computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({ + runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, +})) + +vi.mock('@/lib/copilot/request/lifecycle/start', () => ({ + requestChatTitle: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isDocSandboxEnabled: false, + isHosted: true, +})) + +vi.mock('@/lib/mothership/inbox/agentmail-client', () => ({})) + +vi.mock('@/lib/mothership/inbox/response', () => ({ + sendInboxResponse: mockSendInboxResponse, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + uploadFile: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('owner-1'), +})) + +import { executeInboxTask } from '@/lib/mothership/inbox/executor' + +const INBOX_TASK = { + id: 'task-1', + workspaceId: 'workspace-1', + status: 'received', + fromEmail: 'sender@example.com', + fromName: 'Sender', + subject: 'Task', + bodyPreview: 'Please do this', + bodyText: 'Please do this', + bodyHtml: null, + hasAttachments: false, + agentmailMessageId: null, + chatId: 'chat-1', +} + +const WORKSPACE = { + id: 'workspace-1', + ownerId: 'owner-1', + inboxProviderId: 'provider-1', + inboxSecretScope: 'selected', + inboxMountedSecrets: ['INBOX_KEY'], +} + +describe('Inbox raw-secret actor', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ permission: 'write' }) + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ + success: true, + content: 'done', + contentBlocks: [], + toolCalls: [], + chatId: 'chat-1', + }) + mockSendInboxResponse.mockResolvedValue('response-1') + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'task-1' }]) + .mockResolvedValueOnce([{ model: 'claude-opus-4-8' }]) + }) + + it('gives a workspace member their own raw-secret authority', async () => { + queueTableRows(schemaMock.mothershipInboxTask, [INBOX_TASK]) + queueTableRows(schemaMock.workspace, [WORKSPACE]) + queueTableRows(schemaMock.user, [{ id: 'member-1' }]) + mockGetUserEntityPermissions.mockResolvedValue('write') + + await executeInboxTask('task-1') + + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + userId: 'member-1', + secretActorUserId: 'member-1', + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: ['INBOX_KEY'], + }, + }) + ) + }) + + it('keeps owner execution fallback but removes raw-secret authority for an external sender', async () => { + queueTableRows(schemaMock.mothershipInboxTask, [INBOX_TASK]) + queueTableRows(schemaMock.workspace, [WORKSPACE]) + queueTableRows(schemaMock.user, []) + + await executeInboxTask('task-1') + + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + userId: 'owner-1', + secretActorUserId: null, + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: ['INBOX_KEY'], + }, + }) + ) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 21fcc48c286..4359130a79d 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -18,6 +18,7 @@ import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' @@ -64,6 +65,8 @@ export async function executeInboxTask(taskId: string): Promise { id: workspace.id, ownerId: workspace.ownerId, inboxProviderId: workspace.inboxProviderId, + inboxSecretScope: workspace.inboxSecretScope, + inboxMountedSecrets: workspace.inboxMountedSecrets, }) .from(workspace) .where(eq(workspace.id, inboxTask.workspaceId)) @@ -82,14 +85,15 @@ export async function executeInboxTask(taskId: string): Promise { let responseSent = false try { - const [[claimed], userId] = await Promise.all([ + const [[claimed], actor] = await Promise.all([ db .update(mothershipInboxTask) .set({ status: 'processing', processingStartedAt: new Date() }) .where(and(eq(mothershipInboxTask.id, taskId), eq(mothershipInboxTask.status, 'received'))) .returning({ id: mothershipInboxTask.id }), - resolveUserId(inboxTask.fromEmail, ws), + resolveInboxExecutionActor(inboxTask.fromEmail, ws), ]) + const userId = actor.executionUserId if (!claimed) { logger.info('Task already claimed by another execution, skipping', { taskId }) @@ -252,6 +256,12 @@ export async function executeInboxTask(taskId: string): Promise { autoExecuteTools: true, interactive: false, billingAttribution, + ...(userPermission ? { userPermission } : {}), + secretActorUserId: actor.secretActorUserId, + secretMountPolicy: normalizeSecretMountPolicy({ + secretScope: ws.inboxSecretScope, + mountedSecrets: ws.inboxMountedSecrets, + }), }) const cleanContent = stripThinkingTags(result.content || '') @@ -328,13 +338,19 @@ export async function executeInboxTask(taskId: string): Promise { } /** - * Resolve which user ID to use for execution. - * Match sender email to a workspace member, fallback to workspace owner. + * Resolve the execution and raw-secret actors independently. Workspace members + * execute and mount secrets as themselves. External senders retain the existing + * owner execution fallback but receive no raw-secret actor. */ -async function resolveUserId( +interface InboxExecutionActor { + executionUserId: string + secretActorUserId: string | null +} + +async function resolveInboxExecutionActor( senderEmail: string, ws: { id: string; ownerId: string } -): Promise { +): Promise { const [matchedUser] = await db .select({ id: user.id }) .from(user) @@ -345,11 +361,11 @@ async function resolveUserId( if (matchedUser) { const permission = await getUserEntityPermissions(matchedUser.id, 'workspace', ws.id) if (permission !== null) { - return matchedUser.id + return { executionUserId: matchedUser.id, secretActorUserId: matchedUser.id } } } - return ws.ownerId + return { executionUserId: ws.ownerId, secretActorUserId: null } } /** diff --git a/apps/sim/lib/workflows/executor/execution-state.test.ts b/apps/sim/lib/workflows/executor/execution-state.test.ts index 39aa68c066d..4cbb61c5efb 100644 --- a/apps/sim/lib/workflows/executor/execution-state.test.ts +++ b/apps/sim/lib/workflows/executor/execution-state.test.ts @@ -19,6 +19,7 @@ import { getExecutionInputForWorkflow, getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId, + getTrustedWorkflowToolExecution, } from '@/lib/workflows/executor/execution-state' const EXECUTION_STATE = { @@ -75,6 +76,145 @@ describe('execution state lookup', () => { expect(result).toEqual(EXECUTION_STATE) }) + it('loads a terminal workflow result with an exact persisted Copilot binding', async () => { + const provenance = { + version: 1 as const, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + } + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + finalOutput: { token: 'raw-secret' }, + executionState: { ...EXECUTION_STATE, resolvedSecretTraceProvenance: provenance }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + finalOutput: { token: 'raw-secret' }, + blockLogs: [], + provenance, + }) + }) + + it('accepts a bound complete execution with no activated secrets', async () => { + const provenance = { version: 1 as const, complete: true, entries: [] } + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + trigger: { data: { correlation: { copilotToolCallId: 'tool-call-1' } } }, + executionState: { ...EXECUTION_STATE, resolvedSecretTraceProvenance: provenance }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toMatchObject({ provenance }) + }) + + it('returns validated incomplete provenance so the terminal projector can fail closed', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'failed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 1, complete: false, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toMatchObject({ + status: 'failed', + provenance: { version: 1, complete: false, entries: [] }, + }) + }) + + it('rejects mismatched bindings, malformed provenance, and nonterminal rows', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'another-tool-call' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toBeNull() + + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-2', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 2, complete: true, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-2', 'workflow-1', 'tool-call-1') + ).resolves.toBeNull() + + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-3', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'running', + executionData: {}, + }, + ]) + + await expect( + getTrustedWorkflowToolExecution('execution-3', 'workflow-1', 'tool-call-1') + ).resolves.toBeNull() + }) + it('materializes externalized execution data when reusing workflow input', async () => { const slimExecutionData = { traceStoreRef: { diff --git a/apps/sim/lib/workflows/executor/execution-state.ts b/apps/sim/lib/workflows/executor/execution-state.ts index c854890d71e..945accbb7fb 100644 --- a/apps/sim/lib/workflows/executor/execution-state.ts +++ b/apps/sim/lib/workflows/executor/execution-state.ts @@ -4,6 +4,10 @@ import { isRecordLike } from '@sim/utils/object' import { and, desc, eq, or, sql } from 'drizzle-orm' import { materializeExecutionData, TRACE_STORE_REF_KEY } from '@/lib/logs/execution/trace-store' import type { SerializableExecutionState } from '@/executor/execution/types' +import { + isResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceProvenanceV1, +} from '@/executor/utils/resolved-secret-trace-registry' const LATEST_EXECUTION_STATE_CANDIDATE_LIMIT = 10 @@ -54,9 +58,44 @@ interface ExecutionStateRow { executionId: string workflowId: string | null workspaceId: string + status?: string executionData: unknown } +export interface TrustedWorkflowToolExecution { + executionId: string + workflowId: string + status: 'completed' | 'failed' | 'cancelled' + finalOutput?: unknown + error?: string + blockLogs: SerializableExecutionState['blockLogs'] + provenance: ResolvedSecretTraceProvenanceV1 +} + +async function getExecutionStateRow( + executionId: string, + workflowId: string +): Promise { + const [row] = await db + .select({ + executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + status: workflowExecutionLogs.status, + executionData: workflowExecutionLogs.executionData, + }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.workflowId, workflowId) + ) + ) + .limit(1) + + return row +} + async function materializeExecutionDataFromRow( row: ExecutionStateRow | undefined ): Promise | null> { @@ -80,25 +119,60 @@ export async function getExecutionStateForWorkflow( executionId: string, workflowId: string ): Promise { - const [row] = await db - .select({ - executionId: workflowExecutionLogs.executionId, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionData: workflowExecutionLogs.executionData, - }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.workflowId, workflowId) - ) - ) - .limit(1) - + const row = await getExecutionStateRow(executionId, workflowId) return extractExecutionStateFromRow(row) } +/** Loads a terminal workflow result only when its server-persisted Copilot binding matches. */ +export async function getTrustedWorkflowToolExecution( + executionId: string, + workflowId: string, + copilotToolCallId: string +): Promise { + const row = await getExecutionStateRow(executionId, workflowId) + if ( + !row || + (row.status !== 'completed' && row.status !== 'failed' && row.status !== 'cancelled') + ) { + return null + } + + const executionData = await materializeExecutionDataFromRow(row) + const state = extractExecutionState(executionData) + const provenance = state?.resolvedSecretTraceProvenance + const topLevelCorrelation = executionData?.correlation + const triggerCorrelation = isRecordLike(executionData?.trigger) + ? executionData.trigger.data + : undefined + const correlation = isRecordLike(topLevelCorrelation) + ? topLevelCorrelation + : isRecordLike(triggerCorrelation) && isRecordLike(triggerCorrelation.correlation) + ? triggerCorrelation.correlation + : undefined + + if ( + !executionData || + !state || + !isResolvedSecretTraceProvenanceV1(provenance) || + !isRecordLike(correlation) || + correlation.copilotToolCallId !== copilotToolCallId + ) { + return null + } + + return { + executionId, + workflowId, + status: row.status, + ...(Object.hasOwn(executionData, 'finalOutput') + ? { finalOutput: executionData.finalOutput } + : {}), + ...(typeof executionData.error === 'string' ? { error: executionData.error } : {}), + blockLogs: state.blockLogs, + provenance, + } +} + /** * Returns the workflow input recorded for a past execution so a new run can * reuse it by reference. `found` distinguishes a missing execution from an @@ -108,21 +182,7 @@ export async function getExecutionInputForWorkflow( executionId: string, workflowId: string ): Promise<{ found: boolean; input?: unknown }> { - const [row] = await db - .select({ - executionId: workflowExecutionLogs.executionId, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionData: workflowExecutionLogs.executionData, - }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.workflowId, workflowId) - ) - ) - .limit(1) + const row = await getExecutionStateRow(executionId, workflowId) if (!row) { return { found: false } diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts index 404af18c04f..07f541e553f 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts @@ -42,13 +42,27 @@ const multiTriggerConfig = { ], } +const mothershipConfig = { + type: 'mothership', + name: 'Sim Chat', + category: 'blocks', + outputs: {}, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { id: 'mountedSecrets', type: 'dropdown', hideFromCopilot: true }, + ], +} + vi.mock('@/blocks/registry', () => ({ getBlock: (type: string) => type === 'generic_webhook' ? genericWebhookConfig : type === 'github_v2' ? multiTriggerConfig - : undefined, + : type === 'mothership' + ? mothershipConfig + : undefined, })) /** @@ -112,6 +126,29 @@ describe('sanitizeForCopilot knowledge tag subblocks', () => { }) }) +describe('sanitizeForCopilot server-only block inputs', () => { + it('omits Sim Chat secret-mount policy while retaining model-visible inputs', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('chat-1', { + type: 'mothership', + name: 'Sim Chat 1', + enabled: true, + subBlocks: { + prompt: { id: 'prompt', type: 'long-input', value: 'Help me' }, + secretScope: { id: 'secretScope', type: 'dropdown', value: 'selected' }, + mountedSecrets: { + id: 'mountedSecrets', + type: 'dropdown', + value: ['OPENAI_API_KEY'], + }, + }, + }) + ) + + expect(result.blocks['chat-1'].inputs).toEqual({ prompt: 'Help me' }) + }) +}) + /** Builds a one-block workflow for webhook-URL synthesis tests. */ function makeSingleBlockWorkflow(blockId: string, block: Record): WorkflowState { return { diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 2d4f09bba40..812dbb62f6f 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -270,11 +270,14 @@ function isToolInput(value: unknown): value is ToolInput { * already handled by `sanitizeWorkflowForSharing`. */ function sanitizeSubBlocks( - subBlocks: BlockState['subBlocks'] + subBlocks: BlockState['subBlocks'], + hiddenIds: ReadonlySet ): Record { const sanitized: Record = {} Object.entries(subBlocks).forEach(([key, subBlock]) => { + if (hiddenIds.has(key)) return + // Skip null/undefined values if (subBlock.value === null || subBlock.value === undefined) { return @@ -569,7 +572,12 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState { inputs = loopInputs } else { // For regular blocks, sanitize subBlocks - inputs = sanitizeSubBlocks(block.subBlocks) + const hiddenIds = new Set( + (getBlock(block.type)?.subBlocks ?? []) + .filter((subBlock) => subBlock.hideFromCopilot) + .map((subBlock) => subBlock.id) + ) + inputs = sanitizeSubBlocks(block.subBlocks, hiddenIds) const webhookUrl = resolveTriggerWebhookUrl(blockId, block) if (webhookUrl) { diff --git a/apps/sim/lib/workflows/schedules/orchestration.test.ts b/apps/sim/lib/workflows/schedules/orchestration.test.ts index b656490043d..771cdd5840c 100644 --- a/apps/sim/lib/workflows/schedules/orchestration.test.ts +++ b/apps/sim/lib/workflows/schedules/orchestration.test.ts @@ -32,12 +32,15 @@ import { performUpdateJob } from '@/lib/workflows/schedules/orchestration' const BASE_JOB = { id: 'job-1', sourceWorkspaceId: 'workspace-1', + sourceUserId: 'user-1', sourceType: 'job', archivedAt: null, timezone: 'UTC', cronExpression: null, jobTitle: 'Nightly task', status: 'disabled', + secretScope: 'all', + mountedSecrets: [], } describe('performUpdateJob', () => { @@ -81,4 +84,50 @@ describe('performUpdateJob', () => { nextRunAt: new Date('2099-01-01T09:00:00Z'), }) }) + + it('denies task content edits from a non-creator without writing', async () => { + queueTableRows(schemaMock.workflowSchedule, [BASE_JOB]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'workspace-writer', + prompt: 'Changed prompt', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'forbidden' }) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + + it('allows a non-creator to pause a task', async () => { + queueTableRows(schemaMock.workflowSchedule, [{ ...BASE_JOB, status: 'active' }]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'workspace-writer', + status: 'paused', + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({ status: 'disabled' }) + }) + + it('persists a canonical selected secret policy for the creator', async () => { + queueTableRows(schemaMock.workflowSchedule, [BASE_JOB]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'user-1', + secretScope: 'selected', + mountedSecrets: [' B ', 'A', 'B'], + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({ + secretScope: 'selected', + mountedSecrets: ['B', 'A'], + }) + }) }) diff --git a/apps/sim/lib/workflows/schedules/orchestration.ts b/apps/sim/lib/workflows/schedules/orchestration.ts index 47da9c5e71d..28a49eec405 100644 --- a/apps/sim/lib/workflows/schedules/orchestration.ts +++ b/apps/sim/lib/workflows/schedules/orchestration.ts @@ -6,6 +6,10 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import type { ScheduleContext } from '@/lib/api/contracts/schedules' +import { + normalizeSecretMountPolicy, + type SecretMountScope, +} from '@/lib/copilot/secret-mount-policy' import { captureServerEvent } from '@/lib/posthog/server' import { computeNextRunAt, @@ -15,7 +19,7 @@ import { const logger = createLogger('ScheduleOrchestration') -type ScheduleErrorCode = 'not_found' | 'validation' | 'internal' +type ScheduleErrorCode = 'not_found' | 'forbidden' | 'validation' | 'internal' interface ActorMetadata { actorName?: string | null @@ -39,6 +43,8 @@ export interface PerformCreateJobParams extends ActorMetadata { endsAt?: string | null /** `@`-mentioned resources / `/`-invoked skills captured with the prompt. */ contexts?: ScheduleContext[] | null + secretScope?: SecretMountScope + mountedSecrets?: string[] sourceChatId?: string | null sourceTaskName?: string | null } @@ -68,6 +74,8 @@ export interface PerformUpdateJobParams extends ActorMetadata { maxRuns?: number | null endsAt?: string | null contexts?: ScheduleContext[] | null + secretScope?: SecretMountScope + mountedSecrets?: string[] } export interface PerformExcludeOccurrenceParams extends ActorMetadata { @@ -192,6 +200,7 @@ export async function performCreateJob( try { const id = generateId() const now = new Date() + const secretMountPolicy = normalizeSecretMountPolicy(params) await db.insert(workflowSchedule).values({ id, workflowId: null, @@ -217,6 +226,8 @@ export async function performCreateJob( sourceTaskName: params.sourceTaskName || null, sourceUserId: params.userId, sourceWorkspaceId: params.workspaceId, + secretScope: secretMountPolicy.secretScope, + mountedSecrets: secretMountPolicy.mountedSecrets, }) const [schedule] = await db @@ -284,6 +295,27 @@ export async function performUpdateJob( if (!job) return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' } + const hasCreatorOnlyUpdate = + params.title !== undefined || + params.prompt !== undefined || + params.cronExpression !== undefined || + params.time !== undefined || + params.timezone !== undefined || + params.lifecycle !== undefined || + params.successCondition !== undefined || + params.maxRuns !== undefined || + params.endsAt !== undefined || + params.contexts !== undefined || + params.secretScope !== undefined || + params.mountedSecrets !== undefined + if (hasCreatorOnlyUpdate && job.sourceUserId !== params.userId) { + return { + success: false, + error: 'Only the task creator can edit this task', + errorCode: 'forbidden', + } + } + const updates: Partial = { updatedAt: new Date() } if (params.title !== undefined) updates.jobTitle = params.title.trim() if (params.prompt !== undefined) updates.prompt = params.prompt.trim() @@ -312,6 +344,14 @@ export async function performUpdateJob( if (params.successCondition !== undefined) updates.successCondition = params.successCondition if (params.maxRuns !== undefined) updates.maxRuns = params.maxRuns if (params.contexts !== undefined) updates.contexts = params.contexts + if (params.secretScope !== undefined || params.mountedSecrets !== undefined) { + const secretMountPolicy = normalizeSecretMountPolicy({ + secretScope: params.secretScope ?? job.secretScope, + mountedSecrets: params.mountedSecrets ?? job.mountedSecrets, + }) + updates.secretScope = secretMountPolicy.secretScope + updates.mountedSecrets = secretMountPolicy.mountedSecrets + } const effectiveStatus = updates.status ?? job.status let endsAt: Date | null = job.endsAt diff --git a/apps/sim/lib/workflows/subblocks/options.ts b/apps/sim/lib/workflows/subblocks/options.ts index 8f5baab9b33..bf97f149a21 100644 --- a/apps/sim/lib/workflows/subblocks/options.ts +++ b/apps/sim/lib/workflows/subblocks/options.ts @@ -1,7 +1,13 @@ +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' import { fetchWorkspaceEnvironment } from '@/lib/environment/api' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { environmentKeys, WORKSPACE_ENVIRONMENT_STALE_TIME } from '@/hooks/queries/environment' import { getSandboxListQueryOptions, type SandboxListResponse } from '@/hooks/queries/sandboxes' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { + fetchWorkspaceCredentialList, + WORKSPACE_CREDENTIAL_LIST_STALE_TIME, +} from '@/hooks/queries/utils/fetch-workspace-credentials' import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -70,6 +76,21 @@ export async function fetchWorkspaceSecretNameOptions(): Promise ({ id: name, label: name })) } +/** Loads only secret names the current actor may mount as plaintext into Copilot code. */ +export async function fetchWorkspaceRawSecretNameOptions(): Promise { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + + const credentials = await getQueryClient().fetchQuery({ + queryKey: workspaceCredentialKeys.list(workspaceId), + queryFn: ({ signal }: { signal?: AbortSignal }) => + fetchWorkspaceCredentialList(workspaceId, signal), + staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, + }) + + return selectRawMountableSecretNames(credentials).map((name) => ({ id: name, label: name })) +} + /** * Labels a sandbox for the picker. The name is what identifies it, so that is all * the label carries by default — the block's own list is already scoped to one diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index f39614c6987..ef27aa74c40 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -329,6 +329,16 @@ export class Serializer { enabled: block.enabled, } + const privateInputIds = new Set() + for (const subBlock of blockConfig.subBlocks) { + if (!subBlock.hideFromCopilot) continue + privateInputIds.add(subBlock.id) + if (subBlock.canonicalParamId) privateInputIds.add(subBlock.canonicalParamId) + } + if (privateInputIds.size > 0) { + serialized.privateInputIds = [...privateInputIds] + } + if (block.data?.canonicalModes) { serialized.canonicalModes = block.data.canonicalModes as Record } diff --git a/apps/sim/serializer/private-inputs.test.ts b/apps/sim/serializer/private-inputs.test.ts new file mode 100644 index 00000000000..d97e0e6d109 --- /dev/null +++ b/apps/sim/serializer/private-inputs.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BlockState } from '@/stores/workflows/workflow/types' + +const { mockGetBlock } = vi.hoisted(() => ({ + mockGetBlock: vi.fn(), +})) + +vi.mock('@/blocks', () => ({ + getBlock: mockGetBlock, +})) + +vi.mock('@/tools/metadata', () => ({ + getToolParams: vi.fn(() => undefined), +})) + +import { Serializer } from '@/serializer' + +describe('Serializer private inputs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue({ + name: 'Private lifecycle block', + description: 'Test block', + category: 'blocks', + bgColor: '#000000', + tools: { + access: ['private_lifecycle'], + config: { tool: () => 'private_lifecycle' }, + }, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { + id: 'mountedSecretsAdvanced', + canonicalParamId: 'mountedSecrets', + type: 'dropdown', + hideFromCopilot: true, + }, + ], + inputs: { + prompt: { type: 'string' }, + secretScope: { type: 'string' }, + mountedSecrets: { type: 'json' }, + }, + outputs: {}, + }) + }) + + it('derives executor-private input ids from block metadata', () => { + const block = { + id: 'block-1', + type: 'private_lifecycle', + name: 'Private lifecycle block', + position: { x: 0, y: 0 }, + subBlocks: { + prompt: { id: 'prompt', type: 'long-input', value: 'Run the task' }, + secretScope: { id: 'secretScope', type: 'dropdown', value: 'selected' }, + mountedSecretsAdvanced: { + id: 'mountedSecretsAdvanced', + type: 'dropdown', + value: ['API_KEY'], + }, + }, + outputs: {}, + enabled: true, + } as BlockState + + const serialized = new Serializer().serializeWorkflow({ [block.id]: block }, [], {}) + + expect(serialized.blocks[0].privateInputIds).toEqual([ + 'secretScope', + 'mountedSecretsAdvanced', + 'mountedSecrets', + ]) + }) +}) diff --git a/apps/sim/serializer/types.ts b/apps/sim/serializer/types.ts index 8d7bc56e4ed..2fb123ecee9 100644 --- a/apps/sim/serializer/types.ts +++ b/apps/sim/serializer/types.ts @@ -40,6 +40,8 @@ export interface SerializedBlock { enabled: boolean /** Canonical mode overrides from block.data (used by agent handler for tool param resolution) */ canonicalModes?: Record + /** Server-only lifecycle input ids omitted from execution-log projections. */ + privateInputIds?: string[] } export interface SerializedLoop { diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index ccec1bfc8b1..86bef7b4783 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -26,6 +26,7 @@ import { import { sleep } from '@sim/utils/helpers' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { ANONYMOUS_SECRET_TRACE_REPLACEMENT, ResolvedSecretTraceRegistry, @@ -709,6 +710,88 @@ describe('executeTool Function', () => { ]) }) + it('fails concurrent projection closed while custom-tool provenance is pending', async () => { + const secret = 'custom-tool-secret-value' + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'API_KEY', + plaintext: secret, + encryptedValue: 'encrypted-value', + }, + ]) + mockGetToolAsync.mockResolvedValueOnce({ + id: 'custom_pending-provenance', + name: 'Pending provenance custom tool', + description: 'Tests late provenance activation', + version: '1.0.0', + params: {}, + request: { + url: '/api/function/execute', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: () => ({ code: 'return {{API_KEY}}', envVars: { API_KEY: secret } }), + }, + transformResponse: async (response: Response) => { + const data = await response.json() + return { success: true, output: data.output } + }, + }) + + let resolveRequest!: (response: Response) => void + let markRequestStarted!: () => void + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve + }) + global.fetch = Object.assign( + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve + markRequestStarted() + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const execution = executeTool( + 'custom_pending-provenance', + { envVars: { API_KEY: secret } }, + { + executionContext: createToolExecutionContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await requestStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).not.toHaveProperty('output') + + resolveRequest( + new Response( + JSON.stringify({ + success: true, + output: { result: secret }, + __resolvedSecretNames: ['API_KEY'], + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-sim-private-tool-metadata': 'resolved-secret-names-v1', + }, + } + ) + ) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).toMatchObject({ output: { result: '{{API_KEY}}' } }) + }) + it('keeps the Function result unchanged when requested provenance is missing', async () => { const registry = new ResolvedSecretTraceRegistry() global.fetch = Object.assign( @@ -2254,6 +2337,74 @@ describe('MCP Tool Execution', () => { ]) }) + it('fails concurrent projection closed while MCP provenance is pending', async () => { + const secret = 'mcp-secret-value' + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'test-user', + workspaceId: 'workspace-456', + }) + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ decrypted: secret }) + + let resolveRequest!: (response: Response) => void + let markRequestStarted!: () => void + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve + }) + global.fetch = Object.assign( + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve + markRequestStarted() + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const execution = executeTool( + 'mcp-123-list_files', + { path: '/test' }, + { + executionContext: createToolExecutionContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await requestStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { value: secret } }, registry) + ).not.toHaveProperty('output') + + resolveRequest( + new Response( + JSON.stringify({ + success: true, + data: { output: { content: [{ type: 'text', text: secret }] } }, + __resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-token' }], + scope: { userId: 'test-user', workspaceId: 'workspace-456' }, + }, + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-sim-private-tool-metadata': 'resolved-secret-provenance-v1', + }, + } + ) + ) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { value: secret } }, registry) + ).toMatchObject({ output: { value: '{{MCP_TOKEN}}' } }) + }) + it('rejects unmarked MCP provenance instead of trusting a response body field', async () => { const registry = new ResolvedSecretTraceRegistry() global.fetch = Object.assign( diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 36f9a0eeb28..499508c0bb1 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1246,6 +1246,7 @@ export async function executeTool( // Hoisted so the outer catch can attribute a thrown failure to the chosen key. let hostedKeyForMetrics: { provider: string; tool: string; key: string } | undefined + let completePendingSecretActivation: (() => void) | undefined try { let tool: ToolConfig | undefined @@ -1271,6 +1272,10 @@ export async function executeTool( ? RESOLVED_SECRET_NAMES_METADATA_V1 : undefined + if (resolvedSecretTraceRegistry && (privateToolMetadataType || toolKind === 'mcp')) { + completePendingSecretActivation = resolvedSecretTraceRegistry.beginPendingActivation() + } + // Runs for ALL tools (not just kinded ones) so the per-tool `deniedTools` // denylist is enforced alongside the existing mcp/custom/skill gates. if (scope.userId && scope.workspaceId) { @@ -1773,6 +1778,8 @@ export async function executeTool( duration, }, } + } finally { + completePendingSecretActivation?.() } } diff --git a/packages/db/migrations/0280_great_riptide.sql b/packages/db/migrations/0280_great_riptide.sql new file mode 100644 index 00000000000..e895e1ec040 --- /dev/null +++ b/packages/db/migrations/0280_great_riptide.sql @@ -0,0 +1,5 @@ +-- migration-safe: additive columns use non-null defaults, so old and new app versions can read and write these rows throughout the deploy. +ALTER TABLE "workflow_schedule" ADD COLUMN "secret_scope" text DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE "workflow_schedule" ADD COLUMN "mounted_secrets" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "inbox_secret_scope" text DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "inbox_mounted_secrets" jsonb DEFAULT '[]'::jsonb NOT NULL; diff --git a/packages/db/migrations/meta/0280_snapshot.json b/packages/db/migrations/meta/0280_snapshot.json new file mode 100644 index 00000000000..0a4dee2c63f --- /dev/null +++ b/packages/db/migrations/meta/0280_snapshot.json @@ -0,0 +1,18398 @@ +{ + "id": "d1c2701c-3233-4ce4-9bf1-3ee3065c8e9b", + "prevId": "4b619949-ee98-4251-b621-5f37a9fa23a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 30be907c184..64de524fd6e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1954,6 +1954,13 @@ "when": 1785542556609, "tag": "0279_collab_doc_state_and_content_version", "breakpoints": true + }, + { + "idx": 280, + "version": "7", + "when": 1785640502989, + "tag": "0280_great_riptide", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 87805620307..e29c6825f91 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -756,6 +756,8 @@ export const workflowSchedule = pgTable( sourceWorkspaceId: text('source_workspace_id').references(() => workspace.id, { onDelete: 'cascade', }), + secretScope: text('secret_scope').notNull().default('all'), + mountedSecrets: jsonb('mounted_secrets').$type().notNull().default([]), jobHistory: jsonb('job_history').$type>(), /** `@`-mentioned resources / `/`-invoked skills captured with the prompt, resolved into the agent run at fire time. */ contexts: jsonb('contexts').$type>>(), @@ -1595,6 +1597,8 @@ export const workspace = pgTable( inboxEnabled: boolean('inbox_enabled').notNull().default(false), inboxAddress: text('inbox_address'), inboxProviderId: text('inbox_provider_id'), + inboxSecretScope: text('inbox_secret_scope').notNull().default('all'), + inboxMountedSecrets: jsonb('inbox_mounted_secrets').$type().notNull().default([]), archivedAt: timestamp('archived_at'), organizationAssignedAt: timestamp('organization_assigned_at'), forkedFromWorkspaceId: text('forked_from_workspace_id').references( diff --git a/packages/testing/src/mocks/logging-session.mock.ts b/packages/testing/src/mocks/logging-session.mock.ts index 0f951db2484..3cefc0eb2e0 100644 --- a/packages/testing/src/mocks/logging-session.mock.ts +++ b/packages/testing/src/mocks/logging-session.mock.ts @@ -5,7 +5,8 @@ import { vi } from 'vitest' * `@/lib/logs/execution/logging-session`. Every instance method is backed by a * shared `vi.fn()` so tests that construct multiple sessions observe identical * mock state. `mockSafeStart` defaults to `true` because callers branch on the - * boolean result. All other methods resolve to `undefined`. + * boolean result. Projection methods return their input; other methods resolve + * to `undefined`. * * @example * ```ts @@ -24,6 +25,10 @@ export const loggingSessionMockFns = { mockSafeStart: vi.fn().mockResolvedValue(true), mockWaitForCompletion: vi.fn().mockResolvedValue(undefined), mockWaitForPostExecution: vi.fn().mockResolvedValue(undefined), + mockSetTrustedExecutionCorrelation: vi.fn(), + mockProjectBlockLogsForDisplay: vi.fn(async (logs: unknown) => logs), + mockProjectDisplayContent: vi.fn(async (content: unknown) => content), + mockProjectLiveDisplayText: vi.fn(async (_field: string, value: string) => ({ value })), mockSafeComplete: vi.fn().mockResolvedValue(undefined), mockSafeCompleteWithError: vi.fn().mockResolvedValue(undefined), mockSafeCompleteWithCancellation: vi.fn().mockResolvedValue(undefined), @@ -47,6 +52,10 @@ function buildLoggingSessionInstance() { safeStart: loggingSessionMockFns.mockSafeStart, waitForCompletion: loggingSessionMockFns.mockWaitForCompletion, waitForPostExecution: loggingSessionMockFns.mockWaitForPostExecution, + setTrustedExecutionCorrelation: loggingSessionMockFns.mockSetTrustedExecutionCorrelation, + projectBlockLogsForDisplay: loggingSessionMockFns.mockProjectBlockLogsForDisplay, + projectDisplayContent: loggingSessionMockFns.mockProjectDisplayContent, + projectLiveDisplayText: loggingSessionMockFns.mockProjectLiveDisplayText, safeComplete: loggingSessionMockFns.mockSafeComplete, safeCompleteWithError: loggingSessionMockFns.mockSafeCompleteWithError, safeCompleteWithCancellation: loggingSessionMockFns.mockSafeCompleteWithCancellation, From b625280b70b4991792971ee571e6c9540d42bc1b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 1 Aug 2026 23:46:01 -0700 Subject: [PATCH 3/8] fix(secrets): simplify copilot mounting flow --- .../content/docs/en/platform/credentials.mdx | 2 +- .../api/copilot/tool-permission/route.test.ts | 151 ------------------ .../app/api/copilot/tool-permission/route.ts | 35 +--- .../agent-group/tool-permission-card.tsx | 47 ++---- .../utils/code-secret-references.test.ts} | 15 +- .../lib/copilot/generated/tool-catalog-v1.ts | 47 +++--- .../lib/copilot/generated/tool-schemas-v1.ts | 46 +++--- .../request/context/request-context.ts | 2 +- .../copilot/request/context/result.test.ts | 1 - .../sim/lib/copilot/request/go/stream.test.ts | 1 - .../copilot/request/handlers/handlers.test.ts | 37 ----- apps/sim/lib/copilot/request/handlers/tool.ts | 2 +- .../lib/copilot/request/lifecycle/run.test.ts | 5 +- apps/sim/lib/copilot/request/lifecycle/run.ts | 27 ++-- .../copilot/request/tools/permission.test.ts | 46 ------ .../lib/copilot/request/tools/permission.ts | 40 +---- apps/sim/lib/copilot/request/types.ts | 1 - .../tools/handlers/function-execute.ts | 2 +- .../secret-mount-materializer.server.test.ts | 33 ++++ .../tools/secret-mount-materializer.server.ts | 6 +- apps/sim/lib/copilot/tools/secret-mount.ts | 20 --- apps/sim/tools/index.test.ts | 47 ++++++ apps/sim/tools/index.ts | 47 +++--- 23 files changed, 202 insertions(+), 458 deletions(-) delete mode 100644 apps/sim/app/api/copilot/tool-permission/route.test.ts rename apps/sim/{lib/copilot/tools/secret-mount.test.ts => executor/utils/code-secret-references.test.ts} (59%) delete mode 100644 apps/sim/lib/copilot/tools/secret-mount.ts diff --git a/apps/docs/content/docs/en/platform/credentials.mdx b/apps/docs/content/docs/en/platform/credentials.mdx index d554efb5305..3502b5f6243 100644 --- a/apps/docs/content/docs/en/platform/credentials.mdx +++ b/apps/docs/content/docs/en/platform/credentials.mdx @@ -79,7 +79,7 @@ Masking is activated only when Sim successfully resolves a value from **Settings Copilot's Function and code-execution tools receive a saved secret only when their code explicitly contains a valid `{{KEY}}` reference. Direct `environmentVariables.KEY` access, shell `$KEY`, dynamic names, literals, and configured-but-unused secrets do not mount a value. Code execution requires workspace write access, and the caller must also be allowed to view the raw value: your own Personal secrets, any secret for which you are a Credential Admin, and Workspace secrets when you are a workspace admin. Credential Members can continue using shared secrets through normal workflow and tool resolution, but cannot mount their plaintext into arbitrary Copilot code. -Interactive code calls require **Allow** for that individual call, even if the tool was previously allowed for the chat or account. Headless surfaces use their saved **Secret access** setting: +Headless surfaces use their saved **Secret access** setting: - **Sim Chat block** — under **Show additional fields** - **Scheduled Tasks** — in the task modal diff --git a/apps/sim/app/api/copilot/tool-permission/route.test.ts b/apps/sim/app/api/copilot/tool-permission/route.test.ts deleted file mode 100644 index e73439e6d62..00000000000 --- a/apps/sim/app/api/copilot/tool-permission/route.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * @vitest-environment node - */ - -import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockGetSession, - mockGetAsyncToolCall, - mockGetRunSegment, - mockRecordToolPermissionDecision, - mockPublishToolPermissionDecision, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockGetAsyncToolCall: vi.fn(), - mockGetRunSegment: vi.fn(), - mockRecordToolPermissionDecision: vi.fn(), - mockPublishToolPermissionDecision: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - -vi.mock('@/lib/copilot/async-runs/repository', () => ({ - getAsyncToolCall: mockGetAsyncToolCall, - getRunSegment: mockGetRunSegment, - recordToolPermissionDecision: mockRecordToolPermissionDecision, -})) - -vi.mock('@/lib/copilot/persistence/tool-permission', () => ({ - TOOL_PERMISSION_DECISION: { - allow: 'allow', - allow_chat: 'allow_chat', - always_allow: 'always_allow', - skip: 'skip', - }, - publishToolPermissionDecision: mockPublishToolPermissionDecision, -})) - -vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ - addAutoAllowedTool: vi.fn(), - addChatAutoAllowedTool: vi.fn(), -})) - -vi.mock('@/lib/copilot/request/otel', () => ({ - withIncomingGoSpan: vi.fn( - async ( - _headers: unknown, - _spanName: unknown, - _attributes: unknown, - callback: (span: { setAttributes: (attributes: unknown) => void }) => Promise - ) => callback({ setAttributes: vi.fn() }) - ), -})) - -import { POST } from './route' - -afterAll(resetEnvFlagsMock) - -describe('Copilot tool permission decisions', () => { - beforeEach(() => { - vi.clearAllMocks() - setEnvFlags({ isCopilotToolPermissionsEnabled: false }) - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockGetRunSegment.mockResolvedValue({ id: 'run-1', userId: 'user-1', chatId: 'chat-1' }) - mockRecordToolPermissionDecision.mockResolvedValue({ - toolCallId: 'call-1', - toolName: 'function_execute', - permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), - }) - }) - - it('accepts one-call approval for a secret-bearing code call while the broad flag is off', async () => { - mockGetAsyncToolCall.mockResolvedValue({ - runId: 'run-1', - toolCallId: 'call-1', - toolName: 'function_execute', - args: { language: 'javascript', code: 'return {{API_KEY}}' }, - permissionDecision: null, - }) - - const response = await POST( - createMockRequest( - 'POST', - { decisions: [{ toolCallId: 'call-1', decision: 'allow' }] }, - {}, - 'http://localhost:3000/api/copilot/tool-permission' - ) - ) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - success: true, - results: [{ toolCallId: 'call-1', decision: 'allow', applied: true }], - }) - expect(mockRecordToolPermissionDecision).toHaveBeenCalledWith('call-1', 'allow') - expect(mockPublishToolPermissionDecision).toHaveBeenCalledWith( - expect.objectContaining({ toolCallId: 'call-1', decision: 'allow' }) - ) - }) - - it('keeps ordinary tool permission decisions closed while the broad flag is off', async () => { - mockGetAsyncToolCall.mockResolvedValue({ - runId: 'run-1', - toolCallId: 'call-1', - toolName: 'terminal', - args: { operation: 'run', args: { command: 'ls' } }, - permissionDecision: null, - }) - - const response = await POST( - createMockRequest( - 'POST', - { decisions: [{ toolCallId: 'call-1', decision: 'allow' }] }, - {}, - 'http://localhost:3000/api/copilot/tool-permission' - ) - ) - - expect(response.status).toBe(404) - expect(mockRecordToolPermissionDecision).not.toHaveBeenCalled() - }) - - it.each(['allow_chat', 'always_allow'] as const)( - 'rejects persistent %s approval for a secret-bearing code call', - async (decision) => { - mockGetAsyncToolCall.mockResolvedValue({ - runId: 'run-1', - toolCallId: 'call-1', - toolName: 'function_execute', - args: { language: 'javascript', code: 'return {{API_KEY}}' }, - permissionDecision: null, - }) - - const response = await POST( - createMockRequest( - 'POST', - { decisions: [{ toolCallId: 'call-1', decision }] }, - {}, - 'http://localhost:3000/api/copilot/tool-permission' - ) - ) - - expect(response.status).toBe(400) - expect(mockRecordToolPermissionDecision).not.toHaveBeenCalled() - } - ) -}) diff --git a/apps/sim/app/api/copilot/tool-permission/route.ts b/apps/sim/app/api/copilot/tool-permission/route.ts index bd70b6a0283..536f0d1b3cc 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.ts @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { isRecordLike } from '@sim/utils/object' import { type NextRequest, NextResponse } from 'next/server' import { copilotToolPermissionContract } from '@/lib/api/contracts/copilot' import { parseRequest, validationErrorResponse } from '@/lib/api/server' @@ -22,14 +21,12 @@ import { } from '@/lib/copilot/persistence/tool-permission/auto-allow' import { authenticateCopilotRequestSessionOnly, - createBadRequestResponse, createInternalServerErrorResponse, createNotFoundResponse, createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' -import { getToolSecretMountNames } from '@/lib/copilot/tools/secret-mount' import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -41,10 +38,6 @@ interface DecisionResult { applied: boolean } -interface RejectedDecision { - rejection: 'permission-feature-disabled' | 'persistent-secret-permission' -} - /** * Records one prompt answer and wakes the orchestrator waiting on it. * @@ -56,7 +49,7 @@ async function applyDecision( toolCallId: string, decision: ToolPermissionDecision, userId: string -): Promise { +): Promise { const existing = await getAsyncToolCall(toolCallId).catch((err) => { logger.warn('Failed to fetch async tool call', { toolCallId, error: getErrorMessage(err) }) return null @@ -72,19 +65,6 @@ async function applyDecision( }) if (!run || run.userId !== userId) return null - const args = isRecordLike(existing.args) ? existing.args : undefined - const mountsSecrets = getToolSecretMountNames(existing.toolName, args).length > 0 - if (!isCopilotToolPermissionsEnabled && !mountsSecrets) { - return { rejection: 'permission-feature-disabled' } - } - if ( - mountsSecrets && - (decision === TOOL_PERMISSION_DECISION.allow_chat || - decision === TOOL_PERMISSION_DECISION.always_allow) - ) { - return { rejection: 'persistent-secret-permission' } - } - const claimed = await recordToolPermissionDecision(toolCallId, decision) if (!claimed) { // Someone already answered. Report their decision rather than pretending @@ -137,6 +117,13 @@ export const POST = withRouteHandler((req: NextRequest) => { { [TraceAttr.RequestId]: tracker.requestId }, async (span) => { try { + // Nothing can legitimately be awaiting a decision while the feature is + // off, so close the endpoint rather than letting it write decisions + // onto rows no orchestrator is waiting on. + if (!isCopilotToolPermissionsEnabled) { + return createNotFoundResponse('Tool permissions are not enabled') + } + const { userId: authenticatedUserId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() @@ -167,12 +154,6 @@ export const POST = withRouteHandler((req: NextRequest) => { const results: DecisionResult[] = [] for (const { toolCallId, decision } of decisions) { const result = await applyDecision(toolCallId, decision, authenticatedUserId) - if (result && 'rejection' in result) { - if (result.rejection === 'permission-feature-disabled') { - return createNotFoundResponse('Tool permissions are not enabled') - } - return createBadRequestResponse('Secret-bearing code calls can only be allowed once') - } if (result) results.push(result) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx index 36f975137c0..830634c4a9d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx @@ -4,7 +4,6 @@ import { useCallback, useEffect, useState } from 'react' import { ChevronDown, Chip, - ChipTag, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -17,7 +16,6 @@ import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { copilotToolPermissionContract } from '@/lib/api/contracts/copilot' -import { getToolSecretMountNames } from '@/lib/copilot/tools/secret-mount' import { generalSettingsKeys } from '@/hooks/queries/general-settings' import { useToolPermissionStore } from '@/stores/tool-permission/store' @@ -130,8 +128,6 @@ export function ToolPermissionCard({ ) const preview = argsPreview(params) - const mountedSecretNames = getToolSecretMountNames(toolName, params) - const mountsSecrets = mountedSecretNames.length > 0 const busy = submitting !== null || isSubmitted if (expired) { @@ -173,39 +169,26 @@ export function ToolPermissionCard({ void submit('allow', [toolCallId])}> Allow - {!mountsSecrets && ( - - - - Don't ask again - - - - void submit('allow_chat', [toolCallId])}> - For this chat - - void submit('always_allow', [toolCallId])}> - For every chat - - - - )} + + + + Don't ask again + + + + void submit('allow_chat', [toolCallId])}> + For this chat + + void submit('always_allow', [toolCallId])}> + For every chat + + + void submit('skip', [toolCallId])}> Skip - {mountsSecrets && ( -
- Secrets - {mountedSecretNames.map((name) => ( - - {`{{${name}}}`} - - ))} -
- )} - {showBulkActions && (
diff --git a/apps/sim/lib/copilot/tools/secret-mount.test.ts b/apps/sim/executor/utils/code-secret-references.test.ts similarity index 59% rename from apps/sim/lib/copilot/tools/secret-mount.test.ts rename to apps/sim/executor/utils/code-secret-references.test.ts index 511e3f5aaae..4ebf81a0ebe 100644 --- a/apps/sim/lib/copilot/tools/secret-mount.test.ts +++ b/apps/sim/executor/utils/code-secret-references.test.ts @@ -2,12 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { FunctionExecute, Read, RunCode } from '@/lib/copilot/generated/tool-catalog-v1' -import { - extractCodeSecretNames, - getToolSecretMountNames, - toolHasSecretMountCapability, -} from '@/lib/copilot/tools/secret-mount' +import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' describe('Copilot code secret declarations', () => { it.each(['javascript', 'python'])( @@ -39,12 +34,4 @@ describe('Copilot code secret declarations', () => { ) ).toEqual([]) }) - - it('uses the generated capability as the sole tool classifier', () => { - expect(toolHasSecretMountCapability(FunctionExecute.id)).toBe(true) - expect(toolHasSecretMountCapability(RunCode.id)).toBe(true) - expect(toolHasSecretMountCapability(Read.id)).toBe(false) - expect(getToolSecretMountNames(Read.id, { code: 'return {{SECRET}}' })).toEqual([]) - expect(getToolSecretMountNames(RunCode.id, { code: 'return {{SECRET}}' })).toEqual(['SECRET']) - }) }) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 56f01990d42..fd19458a712 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -1900,14 +1900,7 @@ export const FunctionExecute: ToolCatalogEntry = { }, requiredPermission: 'write', requiresApproval: true, - capabilities: [ - 'file_input', - 'directory_input', - 'file_output', - 'table_input', - 'table_output', - 'secret_mount', - ], + capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], } export const GenerateApiKey: ToolCatalogEntry = { @@ -3496,21 +3489,27 @@ export const QueryUserTable: ToolCatalogEntry = { type: 'object', description: 'Arguments for the operation', properties: { - filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, - limit: { - type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, + filter: { + type: 'object', + description: + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, - offset: { + limit: { type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', + description: + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a non-null nextCursor in the result means more rows exist (continue with cursor).', }, - rowId: { type: 'string', description: 'Row ID (required for get_row)' }, - sort: { - type: 'object', + order: { + type: 'array', description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', }, + rowId: { type: 'string', description: 'Row ID (required for get_row)' }, tableId: { type: 'string', description: 'Table ID (required for all operations)' }, }, }, @@ -3853,7 +3852,7 @@ export const RunCode: ToolCatalogEntry = { }, requiredPermission: 'write', requiresApproval: true, - capabilities: ['file_input', 'directory_input', 'table_input', 'secret_mount'], + capabilities: ['file_input', 'directory_input', 'table_input'], } export const RunFromBlock: ToolCatalogEntry = { @@ -4749,17 +4748,17 @@ export const UserTable: ToolCatalogEntry = { description: 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, - order: { - type: 'array', - description: - 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', - }, options: { type: 'array', description: 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', items: { type: 'string' }, }, + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + }, outputColumnNames: { type: 'object', description: diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index fd3280da1c7..c00fcd2cce6 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3148,27 +3148,30 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', description: 'Arguments for the operation', properties: { + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, filter: { type: 'object', - description: 'MongoDB-style filter for query_rows', + description: + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, limit: { type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + description: + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a non-null nextCursor in the result means more rows exist (continue with cursor).', }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', }, rowId: { type: 'string', description: 'Row ID (required for get_row)', }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, tableId: { type: 'string', description: 'Table ID (required for all operations)', @@ -4247,6 +4250,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, data: { type: 'object', description: 'Row data as key-value pairs (required for insert_row, update_row)', @@ -4289,7 +4297,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: 'object', description: - 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', + 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', }, groupId: { type: 'string', @@ -4326,7 +4334,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { limit: { type: 'number', description: - 'Maximum rows to return or affect (optional, default 100). Omit on update_rows_by_filter / delete_rows_by_filter to act on every match.', + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor). On update_rows_by_filter / delete_rows_by_filter, caps affected rows; omit to act on every match.', }, mapping: { type: 'object', @@ -4388,10 +4396,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', - }, options: { type: 'array', description: @@ -4400,6 +4404,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', }, }, + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + }, outputColumnNames: { type: 'object', description: @@ -4492,11 +4501,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "Cancellation scope for cancel_table_runs. 'all' cancels in-flight runs across the whole table; 'row' cancels only the row identified by rowId.", enum: ['all', 'row'], }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, tableId: { type: 'string', description: diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index 90d283890c6..1fd556a76bf 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -28,7 +28,7 @@ export function createStreamingContext(overrides?: Partial): S errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), - toolPermissions: { enabled: false, promptSurfaceAvailable: false, autoAllowed: new Set() }, + toolPermissions: { enabled: false, autoAllowed: new Set() }, ...overrides, } } diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index 9bad931cccb..1947b635512 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -34,7 +34,6 @@ function makeContext(): StreamingContext { trace: new TraceCollector(), toolPermissions: { enabled: false, - promptSurfaceAvailable: false, autoAllowed: new Set(), }, } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 237b01ba35c..efa9d8ef7d8 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -111,7 +111,6 @@ function createStreamingContext(): StreamingContext { trace: new TraceCollector(), toolPermissions: { enabled: false, - promptSurfaceAvailable: false, autoAllowed: new Set(), }, } diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 08b8e4e737b..c3f92fe3c06 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -126,7 +126,6 @@ describe('sse-handlers tool lifecycle', () => { errors: [], toolPermissions: { enabled: false, - promptSurfaceAvailable: false, autoAllowed: new Set(), }, } @@ -179,7 +178,6 @@ describe('sse-handlers tool lifecycle', () => { context.runId = 'run-1' context.toolPermissions = { enabled: true, - promptSurfaceAvailable: true, autoAllowed: new Set(), } @@ -209,38 +207,6 @@ describe('sse-handlers tool lifecycle', () => { expect(event.payload.status).toBe('awaiting_approval') }) - it('keeps one-call secret approval available when broad tool permissions are off', async () => { - context.runId = 'run-1' - context.toolPermissions = { - enabled: false, - promptSurfaceAvailable: true, - autoAllowed: new Set(), - } - - const event = { - type: MothershipStreamV1EventType.tool, - payload: { - toolCallId: 'function-secret-1', - toolName: FunctionExecute.id, - arguments: { language: 'javascript', code: 'return {{API_KEY}}' }, - executor: MothershipStreamV1ToolExecutor.sim, - mode: MothershipStreamV1ToolMode.async, - phase: MothershipStreamV1ToolPhase.call, - }, - } satisfies StreamEvent - - await prePersistClientExecutableToolCall(event, context, {}) - - expect(event.payload.status).toBe('awaiting_approval') - expect(upsertAsyncToolCall).toHaveBeenCalledWith({ - runId: 'run-1', - toolCallId: 'function-secret-1', - toolName: FunctionExecute.id, - args: { language: 'javascript', code: 'return {{API_KEY}}' }, - status: MothershipStreamV1AsyncToolRecordStatus.pending, - }) - }) - it('clears a Go-stamped approval frame when the gate is off', async () => { // Go stamps integration calls regardless of Sim's feature flag. Forwarding // that stamp with nothing gating behind it would draw a card whose buttons @@ -249,7 +215,6 @@ describe('sse-handlers tool lifecycle', () => { context.runId = 'run-1' context.toolPermissions = { enabled: false, - promptSurfaceAvailable: true, autoAllowed: new Set(), } @@ -277,7 +242,6 @@ describe('sse-handlers tool lifecycle', () => { context.runId = 'run-1' context.toolPermissions = { enabled: true, - promptSurfaceAvailable: true, autoAllowed: new Set(), } @@ -307,7 +271,6 @@ describe('sse-handlers tool lifecycle', () => { context.runId = 'run-1' context.toolPermissions = { enabled: true, - promptSurfaceAvailable: true, autoAllowed: new Set(['deploy_api']), } diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index afa1e03db00..6634a625308 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -716,7 +716,7 @@ async function dispatchToolExecution( context, options, startExecution, - !hiddenInUi && context.toolPermissions.promptSurfaceAvailable + !hiddenInUi ) ) return diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index bdbbef344ea..de4782347ba 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -206,7 +206,7 @@ describe('runCopilotLifecycle', () => { } ) - it('keeps only the one-call prompt surface available while the broad flag is off', async () => { + it('stays entirely inert while the flag is off', async () => { let captured: StreamingContext | undefined mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { captured = context @@ -215,7 +215,6 @@ describe('runCopilotLifecycle', () => { await runMothershipTurn() expect(captured?.toolPermissions.enabled).toBe(false) - expect(captured?.toolPermissions.promptSurfaceAvailable).toBe(true) // Never even reads the preference tables when disabled. expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() }) @@ -231,7 +230,6 @@ describe('runCopilotLifecycle', () => { await runMothershipTurn() expect(captured?.toolPermissions.enabled).toBe(true) - expect(captured?.toolPermissions.promptSurfaceAvailable).toBe(true) expect(captured?.toolPermissions.autoAllowed.has('terminal_run')).toBe(true) expect(mockGetAutoAllowedTools).toHaveBeenCalledWith('user-1', 'chat-1') }) @@ -262,7 +260,6 @@ describe('runCopilotLifecycle', () => { ) expect(captured?.toolPermissions.enabled).toBe(false) - expect(captured?.toolPermissions.promptSurfaceAvailable).toBe(false) expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index a8939ba7a24..aa22adc8829 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -111,23 +111,24 @@ export interface CopilotLifecycleOptions extends OrchestratorOptions { /** * Seed the per-request tool permission state. * - * The broad feature flag controls ordinary tool approvals and saved auto-allow - * preferences. `promptSurfaceAvailable` separately records whether this run has - * a visible interactive row that can collect the mandatory one-call approval - * for a secret mount. + * This is the feature's single on-switch: everything downstream — stamping the + * wire frame, holding the tool, drawing the card, persisting a decision — keys + * off `enabled`, so a disabled request behaves exactly as it did before the + * feature existed and never touches the preference tables. + * + * Beyond the flag, gating is limited to interactive mothership chats: that is + * the only surface with a UI that can answer a prompt, so enabling it anywhere + * else would hang the turn until the orchestration timeout with nothing to click. */ async function resolveToolPermissions( options: CopilotLifecycleOptions ): Promise { - const promptSurfaceAvailable = - options.interactive !== false && (options.goRoute ?? '').startsWith('/api/mothership') - const enabled = isCopilotToolPermissionsEnabled && promptSurfaceAvailable - if (!enabled) return { enabled: false, promptSurfaceAvailable, autoAllowed: new Set() } - return { - enabled: true, - promptSurfaceAvailable, - autoAllowed: await getAutoAllowedTools(options.userId, options.chatId), - } + const enabled = + isCopilotToolPermissionsEnabled && + options.interactive !== false && + (options.goRoute ?? '').startsWith('/api/mothership') + if (!enabled) return { enabled: false, autoAllowed: new Set() } + return { enabled: true, autoAllowed: await getAutoAllowedTools(options.userId, options.chatId) } } export async function runCopilotLifecycle( diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index f8cac22e132..f4a0b06c944 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -35,7 +35,6 @@ function makeContext() { const context = createStreamingContext({ runId: 'run-1' }) context.toolPermissions = { enabled: true, - promptSurfaceAvailable: true, autoAllowed: new Set(), } context.trace = new TraceCollector() @@ -95,18 +94,6 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) - it('ignores saved auto-allow for a secret-bearing code call', () => { - const context = makeContext() - context.toolPermissions.autoAllowed.add('function_execute') - - expect( - toolCallNeedsApproval('function_execute', context, {}, false, { - language: 'javascript', - code: 'return {{API_KEY}}', - }) - ).toBe(true) - }) - it('never gates a non-interactive run, which has nobody to answer the prompt', () => { expect( toolCallNeedsApproval('terminal', makeContext(), { interactive: false }, false, runCall) @@ -119,18 +106,6 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) - it('still gates secret-bearing code when the permission surface is disabled', () => { - const context = makeContext() - context.toolPermissions.enabled = false - - expect( - toolCallNeedsApproval('function_execute', context, {}, false, { - language: 'javascript', - code: 'return {{API_KEY}}', - }) - ).toBe(true) - }) - it('gates a resolved integration operation off the frame Go stamped', () => { // gmail_read_v2 is request-local: it is not in the catalog at all, so the // only thing marking it is the awaiting_approval status on the frame. @@ -309,27 +284,6 @@ describe('runGatedToolExecution', () => { expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) }) - it.each(['allow_chat', 'always_allow'] as const)( - 'refuses a persisted %s decision for a secret-bearing code call', - async (decision) => { - const context = makeContext() - const toolCall = makeToolCall() - toolCall.name = 'function_execute' - toolCall.params = { language: 'javascript', code: 'return {{API_KEY}}' } - const execute = vi.fn().mockResolvedValue({ status: 'success' }) - waitForToolPermissionDecision.mockResolvedValue({ toolCallId: 'call-1', decision }) - - const signal = await gate(context, toolCall, execute, []) - - expect(execute).not.toHaveBeenCalled() - expect(signal.status).toBe('success') - expect(toolCall.result?.output).toMatchObject({ - reason: 'secret_permission_requires_one_time_allow', - }) - expect(context.toolPermissions.autoAllowed.has('function_execute')).toBe(false) - } - ) - it('does not suppress later prompts for a one-off allow', async () => { const context = makeContext() const toolCall = makeToolCall() diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts index 050cac3b5c2..a9829c8c155 100644 --- a/apps/sim/lib/copilot/request/tools/permission.ts +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -16,7 +16,6 @@ import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { decisionAllowsExecution, decisionSuppressesFuturePrompts, - TOOL_PERMISSION_DECISION, waitForToolPermissionDecision, } from '@/lib/copilot/persistence/tool-permission' import { withCopilotSpan } from '@/lib/copilot/request/otel' @@ -28,7 +27,6 @@ import type { ToolCallState, } from '@/lib/copilot/request/types' import { getToolEntry, toolRequiresApproval } from '@/lib/copilot/tool-executor' -import { getToolSecretMountNames } from '@/lib/copilot/tools/secret-mount' const logger = createLogger('CopilotToolPermissionGate') @@ -73,11 +71,8 @@ export function toolCallNeedsApproval( /** The call's arguments, for a tool whose gate depends on what it is doing. */ args?: Record ): boolean { - if (options.interactive === false) return false - - const mountsSecrets = getToolSecretMountNames(toolName, args).length > 0 - if (mountsSecrets) return true if (!context.toolPermissions.enabled) return false + if (options.interactive === false) return false if (!frameRequestsApproval) { if (!toolRequiresApproval(toolName)) return false @@ -113,14 +108,6 @@ function noPromptOutput(toolName: string) { } } -function persistentSecretPermissionOutput(toolName: string) { - return { - skipped: true, - reason: 'secret_permission_requires_one_time_allow', - message: `${toolName} requested secrets and requires a one-time Allow decision. Nothing was executed.`, - } -} - /** * Tell the client how a gated call ended without executing. * @@ -285,30 +272,7 @@ export function runGatedToolExecution( span.setAttribute(TraceAttr.CopilotAsyncToolPermissionDecision, decision.decision) - const mountsSecrets = getToolSecretMountNames(toolName, args).length > 0 - if ( - mountsSecrets && - (decision.decision === TOOL_PERMISSION_DECISION.allow_chat || - decision.decision === TOOL_PERMISSION_DECISION.always_allow) - ) { - const output = persistentSecretPermissionOutput(toolName) - setTerminalToolCallState(toolCall, { - status: MothershipStreamV1ToolOutcome.skipped, - output, - }) - markToolResultSeen(toolCallId) - await emitGateResult( - toolCallId, - toolName, - executor, - MothershipStreamV1ToolOutcome.skipped, - output, - options - ) - return { status: MothershipStreamV1ToolOutcome.success, message: output.message } - } - - if (decisionSuppressesFuturePrompts(decision.decision) && !mountsSecrets) { + if (decisionSuppressesFuturePrompts(decision.decision)) { // Same-turn effect: a second call to this tool later in the turn must // not re-prompt. The durable write (chat row or user settings) happens // in the endpoint. diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index e54c54341d1..bf4908896db 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -179,7 +179,6 @@ export interface StreamingContext { */ toolPermissions: { enabled: boolean - promptSurfaceAvailable: boolean autoAllowed: Set } } diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 3f7b4e5f907..45634e9792d 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,7 +1,6 @@ import { createLogger } from '@sim/logger' import { applySecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' -import { extractCodeSecretNames } from '@/lib/copilot/tools/secret-mount' import { CopilotCodeSecretAccessError, type MaterializedCopilotCodeSecrets, @@ -31,6 +30,7 @@ import { hasCloudStorage, } from '@/lib/uploads/core/storage-service' import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' +import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeTool as executeAppTool } from '@/tools' diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts index a900bf71556..6139a1b2b24 100644 --- a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts @@ -9,6 +9,7 @@ import { queueTableRows, resetDbChainMock, } from '@sim/testing' +import { or } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({ @@ -68,6 +69,17 @@ function credentialRow( } } +function mockSqlText(value: unknown): string { + if (typeof value !== 'object' || value === null || !('toSQL' in value)) { + throw new Error('Expected a mock SQL fragment') + } + const toSQL = value.toSQL + if (typeof toSQL !== 'function') throw new Error('Expected a mock SQL renderer') + const rendered = toSQL.call(value) as { sql?: unknown } + if (typeof rendered.sql !== 'string') throw new Error('Expected rendered SQL text') + return rendered.sql +} + describe('materializeCopilotCodeSecrets', () => { beforeEach(() => { vi.clearAllMocks() @@ -100,6 +112,27 @@ describe('materializeCopilotCodeSecrets', () => { }) }) + it('casts stored JSON values before using JSONB operators', async () => { + queueSources({ personal: { API_KEY: 'personal-cipher' } }) + + await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + for (const [selection] of dbChainMockFns.select.mock.calls.slice(0, 2)) { + const fields = selection as Record + expect(mockSqlText(fields.variables)).toContain("coalesce(?::jsonb, '{}'::jsonb)") + expect(mockSqlText(fields.overLimitNames)).toContain("coalesce(?::jsonb, '{}'::jsonb)") + } + + const personalCredentialPredicate = vi.mocked(or).mock.calls[0]?.[1] + expect(mockSqlText(personalCredentialPredicate)).toContain( + "coalesce(?::jsonb, '{}'::jsonb) ? ?" + ) + }) + it('lets a workspace admin mount workspace secrets with workspace precedence', async () => { mockCheckWorkspaceAccess.mockResolvedValue({ exists: true, diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts index 064090c705d..b8efbb4990c 100644 --- a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts @@ -83,7 +83,7 @@ function requestedVariables(column: AnyPgColumn, names: readonly string[]) { return sql>`coalesce( ( select jsonb_object_agg(entry.key, entry.value) - from jsonb_each_text(coalesce(${column}, '{}'::jsonb)) as entry(key, value) + from jsonb_each_text(coalesce(${column}::jsonb, '{}'::jsonb)) as entry(key, value) where entry.key in (${keys}) and octet_length(entry.value) <= ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} ), @@ -99,7 +99,7 @@ function requestedOverLimitNames(column: AnyPgColumn, names: readonly string[]) return sql`coalesce( ( select jsonb_agg(entry.key order by entry.key) - from jsonb_each_text(coalesce(${column}, '{}'::jsonb)) as entry(key, value) + from jsonb_each_text(coalesce(${column}::jsonb, '{}'::jsonb)) as entry(key, value) where entry.key in (${keys}) and octet_length(entry.value) > ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} ), @@ -195,7 +195,7 @@ export async function materializeCopilotCodeSecrets(params: { eq(credentialMember.status, 'active'), or( eq(credential.type, 'env_workspace'), - sql`coalesce(${environment.variables}, '{}'::jsonb) ? ${credential.envKey}` + sql`coalesce(${environment.variables}::jsonb, '{}'::jsonb) ? ${credential.envKey}` ) ) ) diff --git a/apps/sim/lib/copilot/tools/secret-mount.ts b/apps/sim/lib/copilot/tools/secret-mount.ts deleted file mode 100644 index 3a376eba664..00000000000 --- a/apps/sim/lib/copilot/tools/secret-mount.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' -import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' - -export { extractCodeSecretNames } from '@/executor/utils/code-secret-references' - -export const SECRET_MOUNT_CAPABILITY = 'secret_mount' as const - -export function toolHasSecretMountCapability(toolName: string): boolean { - const capabilities = TOOL_CATALOG[toolName]?.capabilities - return Array.isArray(capabilities) && capabilities.includes(SECRET_MOUNT_CAPABILITY) -} - -/** Returns the explicit secret names requested by a catalog-declared secret-mounting tool call. */ -export function getToolSecretMountNames( - toolName: string, - params: Record | undefined -): string[] { - if (!toolHasSecretMountCapability(toolName) || !params) return [] - return extractCodeSecretNames(params.code, params.language) -} diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 86bef7b4783..579dedc54ff 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -1864,6 +1864,53 @@ describe('Copilot Env Variable Reference Resolution', () => { expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token') }) + it('fails concurrent projection closed while a user-only secret reference is resolving', async () => { + const secret = 'sntrys_real_token' + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'SENTRY_AUTH_TOKEN', + plaintext: secret, + encryptedValue: 'encrypted-token', + }, + ]) + let resolveEnvironment!: (variables: Record) => void + let markResolutionStarted!: () => void + const resolutionStarted = new Promise((resolve) => { + markResolutionStarted = resolve + }) + mockGetEffectiveDecryptedEnv.mockImplementationOnce( + () => + new Promise>((resolve) => { + resolveEnvironment = resolve + markResolutionStarted() + }) + ) + mockJsonFetch() + + const execution = executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { + executionContext: copilotContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await resolutionStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).not.toHaveProperty('output') + + resolveEnvironment({ SENTRY_AUTH_TOKEN: secret }) + await expect(execution).resolves.toMatchObject({ success: true }) + + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).toMatchObject({ output: { result: '{{SENTRY_AUTH_TOKEN}}' } }) + }) + it('trims whitespace inside the braces like the executor resolver', async () => { const fetchMock = mockJsonFetch() diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 499508c0bb1..3d20a4faaaf 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -243,28 +243,33 @@ async function resolveCopilotEnvReferences( ) } - const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') - const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) - - for (const { paramId, value } of pending) { - const missingKeys: string[] = [] - const resolved = resolveEnvVarReferences(value, envVars, { - allowEmbedded: false, - missingKeys, - onResolved: (name, resolvedValue) => { - resolvedSecretTraceRegistry?.recordResolved(name, resolvedValue) - }, - }) - if (missingKeys.length > 0) { - const scopeHint = scope.workspaceId - ? '' - : ' (no workspace context — only personal variables are available here)' - throw new Error( - `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` + - `Check environment/variables.json for available variable names.` - ) + const completePendingActivation = resolvedSecretTraceRegistry?.beginPendingActivation() + try { + const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') + const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) + + for (const { paramId, value } of pending) { + const missingKeys: string[] = [] + const resolved = resolveEnvVarReferences(value, envVars, { + allowEmbedded: false, + missingKeys, + onResolved: (name, resolvedValue) => { + resolvedSecretTraceRegistry?.recordResolved(name, resolvedValue) + }, + }) + if (missingKeys.length > 0) { + const scopeHint = scope.workspaceId + ? '' + : ' (no workspace context — only personal variables are available here)' + throw new Error( + `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` + + `Check environment/variables.json for available variable names.` + ) + } + params[paramId] = resolved as string } - params[paramId] = resolved as string + } finally { + completePendingActivation?.() } } From 2e0d76d0df62a0b1ebb4ae480c71e1124a4c40a9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 1 Aug 2026 23:51:17 -0700 Subject: [PATCH 4/8] test(secrets): preserve standard tool permissions --- .../copilot/request/tools/permission.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index f4a0b06c944..4cd2d3f7140 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -94,6 +94,18 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it('applies the normal saved permission to code with a secret reference', () => { + const context = makeContext() + context.toolPermissions.autoAllowed.add('function_execute') + + expect( + toolCallNeedsApproval('function_execute', context, {}, false, { + language: 'javascript', + code: 'return {{API_KEY}}', + }) + ).toBe(false) + }) + it('never gates a non-interactive run, which has nobody to answer the prompt', () => { expect( toolCallNeedsApproval('terminal', makeContext(), { interactive: false }, false, runCall) @@ -106,6 +118,18 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it('does not add a secret-specific gate when the permission feature is off', () => { + const context = makeContext() + context.toolPermissions.enabled = false + + expect( + toolCallNeedsApproval('function_execute', context, {}, false, { + language: 'javascript', + code: 'return {{API_KEY}}', + }) + ).toBe(false) + }) + it('gates a resolved integration operation off the frame Go stamped', () => { // gmail_read_v2 is request-local: it is not in the catalog at all, so the // only thing marking it is the awaiting_approval status on the frame. @@ -284,6 +308,23 @@ describe('runGatedToolExecution', () => { expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) }) + it('accepts the normal chat-level decision for code with a secret reference', async () => { + const context = makeContext() + const toolCall = makeToolCall() + toolCall.name = 'function_execute' + toolCall.params = { language: 'javascript', code: 'return {{API_KEY}}' } + const execute = vi.fn().mockResolvedValue({ status: 'success' }) + waitForToolPermissionDecision.mockResolvedValue({ + toolCallId: 'call-1', + decision: 'allow_chat', + }) + + await gate(context, toolCall, execute, []) + + expect(execute).toHaveBeenCalledTimes(1) + expect(context.toolPermissions.autoAllowed.has('function_execute')).toBe(true) + }) + it('does not suppress later prompts for a one-off allow', async () => { const context = makeContext() const toolCall = makeToolCall() From 3d13e885986db37f7cd942012f20b8f5ad8b596d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 2 Aug 2026 00:48:13 -0700 Subject: [PATCH 5/8] fix(copilot): bind workflow tool completions --- .../sim/app/api/copilot/confirm/route.test.ts | 478 ++++++++++++++++++ apps/sim/app/api/copilot/confirm/route.ts | 178 ++++++- .../api/copilot/tool-permission/route.test.ts | 139 +++++ .../[id]/execute/route.async.test.ts | 75 +++ .../app/api/workflows/[id]/execute/route.ts | 20 +- .../utils/resolved-secret-trace-registry.ts | 4 + .../lib/copilot/async-runs/lifecycle.test.ts | 12 + apps/sim/lib/copilot/async-runs/lifecycle.ts | 25 +- .../lib/copilot/async-runs/repository.test.ts | 114 ++++- apps/sim/lib/copilot/async-runs/repository.ts | 110 ++-- .../lib/copilot/generated/tool-catalog-v1.ts | 4 +- .../lib/copilot/generated/tool-schemas-v1.ts | 4 +- .../copilot/persistence/tool-confirm/index.ts | 9 +- .../tool-confirm/tool-confirm.test.ts | 25 +- .../persistence/tool-permission/index.ts | 3 +- .../lib/copilot/request/tools/client.test.ts | 163 +++++- apps/sim/lib/copilot/request/tools/client.ts | 49 +- apps/sim/lib/copilot/tools/workflow-tools.ts | 7 + 18 files changed, 1298 insertions(+), 121 deletions(-) create mode 100644 apps/sim/app/api/copilot/tool-permission/route.test.ts diff --git a/apps/sim/app/api/copilot/confirm/route.test.ts b/apps/sim/app/api/copilot/confirm/route.test.ts index c6c22f26076..b618acaf57b 100644 --- a/apps/sim/app/api/copilot/confirm/route.test.ts +++ b/apps/sim/app/api/copilot/confirm/route.test.ts @@ -12,6 +12,7 @@ const { detachAsyncToolCall, publishToolConfirmation, encryptSecret, + getTrustedWorkflowToolExecution, } = vi.hoisted(() => ({ getAsyncToolCall: vi.fn(), getRunSegment: vi.fn(), @@ -19,6 +20,7 @@ const { detachAsyncToolCall: vi.fn(), publishToolConfirmation: vi.fn(), encryptSecret: vi.fn(), + getTrustedWorkflowToolExecution: vi.fn(), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) @@ -28,6 +30,8 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ getRunSegment, completeAsyncToolCall, detachAsyncToolCall, + getClaimedWorkflowExecutionId: (claimedBy?: string | null) => + claimedBy?.startsWith('workflow:') ? claimedBy.slice('workflow:'.length) : undefined, })) vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ @@ -38,6 +42,10 @@ vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret, })) +vi.mock('@/lib/workflows/executor/execution-state', () => ({ + getTrustedWorkflowToolExecution, +})) + import { POST } from './route' describe('Copilot Confirm API Route', () => { @@ -48,6 +56,7 @@ describe('Copilot Confirm API Route', () => { toolName: 'client_tool', args: { foo: 'bar' }, status: 'running', + claimedBy: 'workflow:execution-1', } beforeEach(() => { @@ -65,6 +74,7 @@ describe('Copilot Confirm API Route', () => { completeAsyncToolCall.mockResolvedValue(existingRow) detachAsyncToolCall.mockResolvedValue(existingRow) encryptSecret.mockResolvedValue({ encrypted: 'sealed-client-result', iv: 'iv' }) + getTrustedWorkflowToolExecution.mockResolvedValue({ status: 'completed' }) }) function createMockPostRequest(body: Record): NextRequest { @@ -253,6 +263,291 @@ describe('Copilot Confirm API Route', () => { expect(publishToolConfirmation).not.toHaveBeenCalled() }) + it('rejects a workflow confirmation before the server starts the tool call', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'forged-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(404) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('rejects a workflow success before its bound execution is terminal', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + }) + ) + + expect(response.status).toBe(404) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it.each(['error', 'cancelled'] as const)( + 'accepts a structural %s when the bound execution has no terminal log', + async (status) => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status, + message: 'untrusted client detail', + data: { output: 'untrusted client output' }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: status === 'cancelled' ? 'cancelled' : 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + ...(status === 'cancelled' ? { reason: 'user_cancelled', cancelledByUser: true } : {}), + }, + error: + status === 'cancelled' + ? 'Workflow execution was cancelled.' + : 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('untrusted client') + } + ) + + it('accepts a trusted completion for an approved call created by the previous release', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + permissionDecision: 'allow', + claimedBy: null, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'legacy-execution', + status: 'completed', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'legacy-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'legacy-execution', + }, + error: null, + }) + }) + + it('accepts a verified terminal execution created before workflow claims existed', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + claimedBy: null, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'legacy-execution', + status: 'completed', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'legacy-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'legacy-execution', + }, + error: null, + }) + }) + + it('rejects a workflow confirmation claimed by another executor', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + claimedBy: 'sim-stream', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'error', + }) + ) + + expect(response.status).toBe(404) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).not.toHaveBeenCalled() + }) + + it('rejects a workflow confirmation for a different claimed execution', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'different-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(404) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('preserves a canonical preflight failure before an execution is bound', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'error', + message: 'untrusted client detail', + }) + ) + + expect(response.status).toBe(200) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { success: false, workflowId: 'workflow-1' }, + error: 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain( + 'untrusted client detail' + ) + }) + + it('downgrades an unverifiable success from a stale client to a structural failure', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'success', + message: 'untrusted success detail', + data: { output: 'untrusted output' }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { success: false, workflowId: 'workflow-1' }, + error: 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('untrusted') + }) + + it('preserves an approved cancellation before an execution is bound', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'cancelled', + message: 'untrusted cancellation detail', + }) + ) + + expect(response.status).toBe(200) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'cancelled', + result: { + success: false, + workflowId: 'workflow-1', + reason: 'user_cancelled', + cancelledByUser: true, + }, + error: 'Workflow execution was cancelled.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain( + 'untrusted cancellation detail' + ) + }) + it('does not publish when another terminal confirmation already won', async () => { completeAsyncToolCall.mockResolvedValueOnce(null) @@ -281,6 +576,57 @@ describe('Copilot Confirm API Route', () => { expect(publishToolConfirmation).not.toHaveBeenCalled() }) + it('acknowledges an idempotent terminal workflow retry without publishing again', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'completed', + claimedBy: null, + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ status: 'success' }) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('acknowledges an idempotent background workflow retry from durable state', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'delivered', + claimedBy: 'workflow:execution-1', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'background', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ status: 'background' }) + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + it('treats a workflow success as a notification and persists only canonical structure', async () => { getAsyncToolCall.mockResolvedValue({ ...existingRow, @@ -341,6 +687,7 @@ describe('Copilot Confirm API Route', () => { toolName: 'run_block', args: { workflowId: 'workflow-1' }, }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ status: 'failed' }) const response = await POST( createMockPostRequest({ @@ -375,6 +722,7 @@ describe('Copilot Confirm API Route', () => { toolName: 'run_workflow', args: { workflowId: 'workflow-1' }, }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ status: 'failed' }) const response = await POST( createMockPostRequest({ @@ -411,6 +759,7 @@ describe('Copilot Confirm API Route', () => { ...existingRow, toolName: 'run_from_block', args: { workflowId: 'stored-workflow' }, + claimedBy: 'workflow:submitted-execution', }) getRunSegment.mockResolvedValue({ id: 'run-1', @@ -499,8 +848,137 @@ describe('Copilot Confirm API Route', () => { timestamp: expect.any(String), data: { workflowId: 'workflow-1', executionId: 'execution-1' }, }) + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123', { + preserveClaim: true, + }) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() }) + it('detaches a background confirmation while its execution request is still binding', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow_until_block', + args: { workflowId: 'workflow-1' }, + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'unbound-execution', + status: 'background', + }) + ) + + expect(response.status).toBe(200) + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123', { + preserveClaim: true, + }) + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + executionId: 'unbound-execution', + status: 'background', + message: 'Workflow execution is continuing in the background.', + timestamp: expect.any(String), + data: { workflowId: 'workflow-1', executionId: 'unbound-execution' }, + }) + }) + + it('accepts a legacy unbound background confirmation without an execution ID', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow_until_block', + args: { workflowId: 'workflow-1' }, + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'background', + }) + ) + + expect(response.status).toBe(200) + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123') + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'background', + message: 'Workflow execution is continuing in the background.', + timestamp: expect.any(String), + data: { workflowId: 'workflow-1' }, + }) + }) + + it('derives workflow outcome from the terminal server execution', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ status: 'failed' }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: 'Workflow execution failed.', + }) + expect(publishToolConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ status: 'error', message: 'Workflow execution failed.' }) + ) + expect(await response.json()).toMatchObject({ status: 'error' }) + }) + + it.each(['error', 'cancelled'] as const)( + 'uses a completed server execution instead of the submitted %s status', + async (submittedStatus) => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'execution-1', + status: 'completed', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: submittedStatus, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: null, + }) + expect(await response.json()).toMatchObject({ status: 'success' }) + } + ) + it('rejects unsupported accepted and rejected confirmation statuses', async () => { const acceptedResponse = await POST( createMockPostRequest({ diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index 71e4a67795e..07ca1052dc2 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -10,11 +10,15 @@ import { ASYNC_TOOL_STATUS, type AsyncCompletionData, type AsyncConfirmationStatus, + isDeliveredAsyncStatus, + isTerminalAsyncStatus, + isWorkflowToolExecutionClaimable, } from '@/lib/copilot/async-runs/lifecycle' import { completeAsyncToolCall, detachAsyncToolCall, getAsyncToolCall, + getClaimedWorkflowExecutionId, getRunSegment, } from '@/lib/copilot/async-runs/repository' import { CopilotConfirmOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' @@ -35,11 +39,14 @@ import { } from '@/lib/copilot/request/tools/client-completion-seal.server' import { createStructuralWorkflowToolCompletionData, + getWorkflowToolCompletionExecutionId, getWorkflowToolCompletionMessage, + getWorkflowToolConfirmationStatus, isWorkflowToolName, resolveWorkflowToolTargetId, } from '@/lib/copilot/tools/workflow-tools' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state' const logger = createLogger('CopilotConfirmAPI') @@ -50,6 +57,14 @@ function getClientToolCompletionMessage(status: AsyncConfirmationStatus): string return 'Tool failed' } +function createConfirmationResponse( + toolCallId: string, + status: AsyncConfirmationStatus, + message: string +): NextResponse { + return NextResponse.json({ success: true, message, toolCallId, status }) +} + /** Atomically finalize or detach a client tool before publishing its wakeup event. */ async function updateToolCallStatus( existing: NonNullable>>, @@ -61,7 +76,9 @@ async function updateToolCallStatus( const toolCallId = existing.toolCallId try { if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { - const detached = await detachAsyncToolCall(toolCallId) + const detached = executionId + ? await detachAsyncToolCall(toolCallId, { preserveClaim: true }) + : await detachAsyncToolCall(toolCallId) if (!detached) return false publishToolConfirmation({ toolCallId, @@ -143,7 +160,13 @@ export const POST = withRouteHandler((req: NextRequest) => { } ) if (!parsed.success) return parsed.response - const { toolCallId, executionId, status, message, data } = parsed.data.body + const { + toolCallId, + executionId: submittedExecutionId, + status, + message, + data, + } = parsed.data.body span.setAttributes({ [TraceAttr.ToolCallId]: toolCallId, [TraceAttr.ToolConfirmationStatus]: status, @@ -181,22 +204,142 @@ export const POST = withRouteHandler((req: NextRequest) => { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + const isWorkflowTool = isWorkflowToolName(existing.toolName || '') + const workflowId = isWorkflowTool + ? resolveWorkflowToolTargetId(existing.args, run.workflowId) + : undefined + + if (isWorkflowTool && isTerminalAsyncStatus(existing.status)) { + const executionId = getWorkflowToolCompletionExecutionId(existing.result) + if ( + executionId && + submittedExecutionId !== undefined && + submittedExecutionId !== executionId + ) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Completed workflow execution not found') + } + + const terminalStatus = getWorkflowToolConfirmationStatus(existing.status) + span.setAttributes({ + [TraceAttr.ToolConfirmationStatus]: terminalStatus, + [TraceAttr.CopilotConfirmOutcome]: CopilotConfirmOutcome.Delivered, + }) + return createConfirmationResponse( + toolCallId, + terminalStatus, + getWorkflowToolCompletionMessage(terminalStatus) + ) + } + + if (isWorkflowTool && isDeliveredAsyncStatus(existing.status)) { + const claimedExecutionId = getClaimedWorkflowExecutionId(existing.claimedBy) + if ( + claimedExecutionId && + submittedExecutionId !== undefined && + submittedExecutionId !== claimedExecutionId + ) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Bound workflow tool call not found') + } + + span.setAttributes({ + [TraceAttr.ToolConfirmationStatus]: ASYNC_TOOL_CONFIRMATION_STATUS.background, + [TraceAttr.CopilotConfirmOutcome]: CopilotConfirmOutcome.Delivered, + }) + return createConfirmationResponse( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.background, + getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background) + ) + } + + const isUnboundTerminalWorkflowOutcome = + status === ASYNC_TOOL_CONFIRMATION_STATUS.error || + status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + const isMutableClientToolCall = isWorkflowTool + ? isWorkflowToolExecutionClaimable(existing.status, existing.permissionDecision) + : existing.status === ASYNC_TOOL_STATUS.running if ( - (isBrowserToolName(existing.toolName) || isTerminalToolName(existing.toolName)) && - existing.status !== ASYNC_TOOL_STATUS.running + (isBrowserToolName(existing.toolName) || + isTerminalToolName(existing.toolName) || + isWorkflowTool) && + !isMutableClientToolCall ) { span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ToolCallNotFound) return createNotFoundResponse('Running client tool call not found') } - const isWorkflowTool = isWorkflowToolName(existing.toolName || '') - const workflowId = isWorkflowTool - ? resolveWorkflowToolTargetId(existing.args, run.workflowId) - : undefined + let effectiveStatus = status + let executionId = submittedExecutionId + + if (isWorkflowTool) { + const claimedExecutionId = getClaimedWorkflowExecutionId(existing.claimedBy) + const hasForeignClaim = + existing.claimedBy !== null && existing.claimedBy !== undefined && !claimedExecutionId + + if ( + hasForeignClaim || + (claimedExecutionId && + submittedExecutionId !== undefined && + submittedExecutionId !== claimedExecutionId) + ) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Bound workflow tool call not found') + } + + const candidateExecutionId = claimedExecutionId ?? submittedExecutionId + const trustedExecution = + status !== ASYNC_TOOL_CONFIRMATION_STATUS.background && + candidateExecutionId && + workflowId + ? await getTrustedWorkflowToolExecution(candidateExecutionId, workflowId, toolCallId) + : null + + if (claimedExecutionId) { + executionId = claimedExecutionId + if (status !== ASYNC_TOOL_CONFIRMATION_STATUS.background) { + if (trustedExecution) { + effectiveStatus = getWorkflowToolConfirmationStatus(trustedExecution.status) + } else if (!isUnboundTerminalWorkflowOutcome) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Completed workflow execution not found') + } + } + } else if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + executionId = submittedExecutionId + } else if (trustedExecution) { + executionId = trustedExecution.executionId + effectiveStatus = getWorkflowToolConfirmationStatus(trustedExecution.status) + } else if (!isUnboundTerminalWorkflowOutcome) { + effectiveStatus = ASYNC_TOOL_CONFIRMATION_STATUS.error + executionId = undefined + } else { + executionId = undefined + } + } + + span.setAttribute(TraceAttr.ToolConfirmationStatus, effectiveStatus) const projected = isWorkflowTool ? { - message: getWorkflowToolCompletionMessage(status), - data: createStructuralWorkflowToolCompletionData(status, workflowId, executionId), + message: getWorkflowToolCompletionMessage(effectiveStatus), + data: createStructuralWorkflowToolCompletionData( + effectiveStatus, + workflowId, + executionId + ), } : { message: getClientToolCompletionMessage(status), @@ -214,7 +357,7 @@ export const POST = withRouteHandler((req: NextRequest) => { const updated = await updateToolCallStatus( existing, - status, + effectiveStatus, projected.message, projected.data, isWorkflowTool ? executionId : undefined @@ -224,8 +367,8 @@ export const POST = withRouteHandler((req: NextRequest) => { logger.error(`[${tracker.requestId}] Failed to update tool call status`, { userId: authenticatedUserId, toolCallId, - status, - internalStatus: status, + status: effectiveStatus, + internalStatus: effectiveStatus, message: projected.message, }) span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.UpdateFailed) @@ -234,12 +377,11 @@ export const POST = withRouteHandler((req: NextRequest) => { } span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Delivered) - return NextResponse.json({ - success: true, - message: projected.message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`, + return createConfirmationResponse( toolCallId, - status, - }) + effectiveStatus, + projected.message || `Tool call ${toolCallId} has been ${effectiveStatus.toLowerCase()}` + ) } catch (error) { const duration = tracker.getDuration() diff --git a/apps/sim/app/api/copilot/tool-permission/route.test.ts b/apps/sim/app/api/copilot/tool-permission/route.test.ts new file mode 100644 index 00000000000..bdc42bd79c1 --- /dev/null +++ b/apps/sim/app/api/copilot/tool-permission/route.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ + +import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + getAsyncToolCall, + getRunSegment, + recordToolPermissionDecision, + publishToolPermissionDecision, + addAutoAllowedTool, + addChatAutoAllowedTool, +} = vi.hoisted(() => ({ + getAsyncToolCall: vi.fn(), + getRunSegment: vi.fn(), + recordToolPermissionDecision: vi.fn(), + publishToolPermissionDecision: vi.fn(), + addAutoAllowedTool: vi.fn(), + addChatAutoAllowedTool: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + getAsyncToolCall, + getRunSegment, + recordToolPermissionDecision, +})) + +vi.mock('@/lib/copilot/persistence/tool-permission', () => ({ + publishToolPermissionDecision, + TOOL_PERMISSION_DECISION: { + allow: 'allow', + allow_chat: 'allow_chat', + always_allow: 'always_allow', + skip: 'skip', + }, +})) + +vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ + addAutoAllowedTool, + addChatAutoAllowedTool, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isCopilotToolPermissionsEnabled: true, +})) + +import { POST } from './route' + +describe('Copilot tool permission API', () => { + beforeEach(() => { + vi.clearAllMocks() + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ + userId: 'user-1', + isAuthenticated: true, + }) + getAsyncToolCall.mockResolvedValue({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: null, + }) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + chatId: 'chat-1', + }) + recordToolPermissionDecision.mockResolvedValue({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: 'allow', + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + addAutoAllowedTool.mockResolvedValue(undefined) + addChatAutoAllowedTool.mockResolvedValue(undefined) + }) + + function createRequest(decision: 'allow' | 'allow_chat' | 'always_allow' | 'skip') { + return new NextRequest('http://localhost:3000/api/copilot/tool-permission', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decisions: [{ toolCallId: 'tool-1', decision }] }), + }) + } + + it.each(['allow', 'allow_chat', 'always_allow', 'skip'] as const)( + 'records the generic %s decision without changing execution state', + async (decision) => { + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: decision, + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + const response = await POST(createRequest(decision)) + + expect(response.status).toBe(200) + expect(recordToolPermissionDecision).toHaveBeenCalledWith('tool-1', decision) + expect(publishToolPermissionDecision).toHaveBeenCalledWith( + expect.objectContaining({ toolCallId: 'tool-1', decision }) + ) + } + ) + + it('uses the same decision path for non-workflow tools', async () => { + const toolName = 'function_execute' + const decision = 'allow' + getAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName, + status: 'pending', + permissionDecision: null, + }) + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName, + status: 'pending', + permissionDecision: decision, + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + const response = await POST(createRequest(decision)) + + expect(response.status).toBe(200) + expect(recordToolPermissionDecision).toHaveBeenCalledWith('tool-1', decision) + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index a9450ed2884..f79075da804 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -29,6 +29,7 @@ import { AsyncJobEnqueueError } from '@/lib/core/async-jobs/types' const { mockAssertBillingAttributionSnapshot, mockClaimExecutionId, + mockClaimWorkflowToolExecution, mockEnqueue, mockExecuteWorkflowCore, mockGenerateId, @@ -52,6 +53,7 @@ const { return value }), mockClaimExecutionId: vi.fn(), + mockClaimWorkflowToolExecution: vi.fn(), mockEnqueue: vi.fn().mockResolvedValue('job-123'), mockExecuteWorkflowCore: vi.fn(), mockGenerateId: vi.fn(() => 'execution-123'), @@ -114,6 +116,7 @@ vi.mock('@/lib/workflows/executor/execution-id-claim', () => ({ })) vi.mock('@/lib/copilot/async-runs/repository', () => ({ + claimWorkflowToolExecution: mockClaimWorkflowToolExecution, getAsyncToolCall: mockGetAsyncToolCall, getRunSegment: mockGetRunSegment, })) @@ -330,6 +333,10 @@ describe('workflow execute async route', () => { key: `workflow-execution-id:${executionId}`, token: `token-${executionId}`, })) + mockClaimWorkflowToolExecution.mockResolvedValue({ + toolCallId: 'copilot-tool-1', + claimedBy: 'workflow:execution-123', + }) mockHasDurableExecutionOwner.mockResolvedValue(false) mockGetAsyncToolCall.mockReset().mockResolvedValue({ toolCallId: 'copilot-tool-1', @@ -427,6 +434,7 @@ describe('workflow execute async route', () => { expect(response.status).toBe(200) expect(streamCompleted).toBe(false) + expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).toHaveBeenCalledWith({ executionId: 'execution-123', requestId: 'req-12345678', @@ -444,7 +452,74 @@ describe('workflow execute async route', () => { expect(body).toContain('execution:completed') }) + it('rejects a competing Copilot workflow execution before logging starts', async () => { + mockClaimWorkflowToolExecution.mockResolvedValueOnce(null) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Copilot workflow tool is already bound to another execution', + }) + expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled() + expect(mockPreprocessExecution).not.toHaveBeenCalled() + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() + }) + + it('binds a workflow execution after its page-hide confirmation detached the waiter', async () => { + mockGetAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'delivered', + claimedBy: null, + }) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(200) + await response.text() + expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') + }) + + it('binds an approved pending workflow call created by the previous release', async () => { + mockGetAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + permissionDecision: 'allow', + claimedBy: null, + }) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(200) + await response.text() + expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') + }) + it.each([ + [ + 'pending tool row', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + }, + { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + ], [ 'terminal tool row', { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index cb3db039d3a..54f69a24f4a 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -19,8 +19,12 @@ import { type BillingAttributionSnapshot, requireBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' -import { ASYNC_TOOL_STATUS } from '@/lib/copilot/async-runs/lifecycle' -import { getAsyncToolCall, getRunSegment } from '@/lib/copilot/async-runs/repository' +import { isWorkflowToolExecutionClaimable } from '@/lib/copilot/async-runs/lifecycle' +import { + claimWorkflowToolExecution, + getAsyncToolCall, + getRunSegment, +} from '@/lib/copilot/async-runs/repository' import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' @@ -157,7 +161,7 @@ async function isValidCopilotWorkflowToolBinding(params: { if ( !toolCall || !isWorkflowToolName(toolCall.toolName) || - (toolCall.status !== ASYNC_TOOL_STATUS.pending && toolCall.status !== ASYNC_TOOL_STATUS.running) + !isWorkflowToolExecutionClaimable(toolCall.status, toolCall.permissionDecision) ) { return false } @@ -1148,6 +1152,16 @@ async function handleExecutePost( ) } + if (copilotToolCallId) { + const boundToolCall = await claimWorkflowToolExecution(copilotToolCallId, executionId) + if (!boundToolCall) { + return NextResponse.json( + { error: 'Copilot workflow tool is already bound to another execution' }, + { status: 409 } + ) + } + } + const loggingSession = new LoggingSession( workflowId, executionId, diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 7f80a5c5790..f96694e76ec 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -544,6 +544,10 @@ export class ResolvedSecretTraceRegistry { return this.complete && this.pendingActivations === 0 } + isPermanentlyIncomplete(): boolean { + return !this.complete + } + markIncomplete(): void { this.complete = false } diff --git a/apps/sim/lib/copilot/async-runs/lifecycle.test.ts b/apps/sim/lib/copilot/async-runs/lifecycle.test.ts index 8cd4fd872e7..ecf31930c50 100644 --- a/apps/sim/lib/copilot/async-runs/lifecycle.test.ts +++ b/apps/sim/lib/copilot/async-runs/lifecycle.test.ts @@ -10,6 +10,7 @@ import { isAsyncTerminalConfirmationStatus, isDeliveredAsyncStatus, isTerminalAsyncStatus, + isWorkflowToolExecutionClaimable, } from './lifecycle' describe('async tool lifecycle helpers', () => { @@ -26,6 +27,17 @@ describe('async tool lifecycle helpers', () => { expect(isDeliveredAsyncStatus(ASYNC_TOOL_STATUS.delivered)).toBe(true) }) + it('claims only dispatched or explicitly approved workflow calls', () => { + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.running, null)).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.delivered, null)).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'allow')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'allow_chat')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'always_allow')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'skip')).toBe(false) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, null)).toBe(false) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.completed, 'allow')).toBe(false) + }) + it('distinguishes background from terminal completion statuses', () => { expect(isAsyncEphemeralConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.background)).toBe(true) expect(isAsyncEphemeralConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.success)).toBe(false) diff --git a/apps/sim/lib/copilot/async-runs/lifecycle.ts b/apps/sim/lib/copilot/async-runs/lifecycle.ts index e54b2f1900a..d86ae06442a 100644 --- a/apps/sim/lib/copilot/async-runs/lifecycle.ts +++ b/apps/sim/lib/copilot/async-runs/lifecycle.ts @@ -1,4 +1,4 @@ -import type { CopilotAsyncToolStatus } from '@sim/db/schema' +import type { CopilotAsyncToolStatus, CopilotToolPermissionDecision } from '@sim/db/schema' import { MothershipStreamV1AsyncToolRecordStatus, MothershipStreamV1ToolOutcome, @@ -6,6 +6,12 @@ import { export const ASYNC_TOOL_STATUS = MothershipStreamV1AsyncToolRecordStatus +export const EXECUTABLE_TOOL_PERMISSION_DECISIONS = [ + 'allow', + 'allow_chat', + 'always_allow', +] as const satisfies readonly CopilotToolPermissionDecision[] + export type AsyncLifecycleStatus = | typeof ASYNC_TOOL_STATUS.pending | typeof ASYNC_TOOL_STATUS.running @@ -81,6 +87,23 @@ export interface AsyncCompletionSignal { data?: AsyncCompletionData } +export function isExecutableToolPermissionDecision( + decision: CopilotToolPermissionDecision | null | undefined +): boolean { + return decision !== null && decision !== undefined && decision !== 'skip' +} + +export function isWorkflowToolExecutionClaimable( + status: CopilotAsyncToolStatus, + permissionDecision: CopilotToolPermissionDecision | null | undefined +): boolean { + return ( + status === ASYNC_TOOL_STATUS.running || + status === ASYNC_TOOL_STATUS.delivered || + (status === ASYNC_TOOL_STATUS.pending && isExecutableToolPermissionDecision(permissionDecision)) + ) +} + export function isTerminalAsyncStatus( status: CopilotAsyncToolStatus | AsyncLifecycleStatus | string | null | undefined ): status is AsyncTerminalStatus { diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index 120bd502575..03d1bda37b7 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -7,8 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { claimCompletedAsyncToolCall, claimPendingAsyncToolCall, + claimWorkflowToolExecution, completeAsyncToolCall, detachAsyncToolCall, + getClaimedWorkflowExecutionId, + recordToolPermissionDecision, replaceTerminalAsyncToolCallResult, upsertAsyncToolCall, } from './repository' @@ -128,6 +131,74 @@ describe('async tool repository single-row semantics', () => { ) }) + it('atomically binds an eligible workflow tool to one execution', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'running', + claimedBy: 'workflow:execution-1', + }, + ]) + + const result = await claimWorkflowToolExecution('workflow-tool', 'execution-1') + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + claimedBy: 'workflow:execution-1', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + status: expect.anything(), + claimedBy: 'workflow:execution-1', + claimedAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + expect(getClaimedWorkflowExecutionId(result?.claimedBy)).toBe('execution-1') + }) + + it('returns null when a workflow tool execution claim loses the race', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect(claimWorkflowToolExecution('workflow-tool', 'execution-2')).resolves.toBeNull() + }) + + it('detaches a bound workflow waiter without releasing its execution claim', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: 'workflow:execution-1', + }, + ]) + + await detachAsyncToolCall('workflow-tool', { preserveClaim: true }) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'delivered', + claimedBy: undefined, + claimedAt: undefined, + }) + ) + }) + + it('records an approved workflow decision without changing execution state', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'pending', + permissionDecision: 'allow', + }, + ]) + + await recordToolPermissionDecision('workflow-tool', 'allow') + + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + permissionDecision: 'allow', + permissionDecidedAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + }) + it('replaces only terminal payload fields after trusted projection', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { @@ -157,25 +228,28 @@ describe('async tool repository single-row semantics', () => { expect(dbChainMockFns.where).toHaveBeenCalled() }) - it('keeps the first finalized pending call identity immutable', async () => { - const pendingRow = { - runId: 'run-1', - toolCallId: 'tool-1', - toolName: 'function_execute', - args: { language: 'javascript', code: 'return {{FIRST_SECRET}}' }, - status: 'pending', - } - dbChainMockFns.limit.mockResolvedValueOnce([pendingRow]) - - const result = await upsertAsyncToolCall({ - runId: 'run-1', - toolCallId: 'tool-1', - toolName: 'function_execute', - args: { language: 'javascript', code: 'return {{SECOND_SECRET}}' }, - status: 'pending', - }) + it.each(['pending', 'running'] as const)( + 'keeps the first finalized call identity immutable after it reaches %s', + async (status) => { + const existingRow = { + runId: 'run-1', + toolCallId: 'tool-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{FIRST_SECRET}}' }, + status, + } + dbChainMockFns.limit.mockResolvedValueOnce([existingRow]) + + const result = await upsertAsyncToolCall({ + runId: 'run-1', + toolCallId: 'tool-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{SECOND_SECRET}}' }, + status: 'pending', + }) - expect(result).toEqual(pendingRow) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) + expect(result).toEqual(existingRow) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + } + ) }) diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 1733b1e70a4..1c220c9a7a6 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -11,7 +11,7 @@ import { import { createLogger } from '@sim/logger' import { filterUndefined } from '@sim/utils/object' import { sanitizeValueForJsonb } from '@sim/utils/string' -import { and, desc, eq, inArray, isNull } from 'drizzle-orm' +import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { markSpanForError } from '@/lib/copilot/request/otel' @@ -19,11 +19,11 @@ import { ASYNC_TOOL_STATUS, type AsyncCompletionData, type AsyncTerminalStatus, - isDeliveredAsyncStatus, - isTerminalAsyncStatus, + EXECUTABLE_TOOL_PERMISSION_DECISIONS, } from './lifecycle' const logger = createLogger('CopilotAsyncRunsRepo') +const WORKFLOW_EXECUTION_CLAIM_PREFIX = 'workflow:' // Resolve the tracer lazily per-call to avoid capturing the NoOp tracer // before NodeSDK installs the global TracerProvider (Next.js 16/Turbopack // can evaluate modules before instrumentation-node.ts finishes). @@ -259,24 +259,10 @@ export async function upsertAsyncToolCall(input: { }, async () => { const existing = await getAsyncToolCall(input.toolCallId) + if (existing) return existing + const incomingStatus = input.status ?? 'pending' - if (existing?.status === 'pending' && incomingStatus === 'pending') { - return existing - } - if ( - existing && - (isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) && - !isTerminalAsyncStatus(incomingStatus) && - !isDeliveredAsyncStatus(incomingStatus) - ) { - logger.info('Ignoring async tool upsert that would downgrade terminal state', { - toolCallId: input.toolCallId, - existingStatus: existing.status, - incomingStatus, - }) - return existing - } - const effectiveRunId = input.runId ?? existing?.runId ?? null + const effectiveRunId = input.runId ?? null if (!effectiveRunId) { logger.warn('upsertAsyncToolCall missing runId and no existing row', { toolCallId: input.toolCallId, @@ -301,21 +287,10 @@ export async function upsertAsyncToolCall(input: { ...(sealedContext !== undefined ? { result: sealedContext } : {}), updatedAt: now, }) - .onConflictDoUpdate({ - target: copilotAsyncToolCalls.toolCallId, - set: { - runId: effectiveRunId, - checkpointId: input.checkpointId ?? null, - toolName: input.toolName, - args, - status: incomingStatus, - ...(sealedContext !== undefined ? { result: sealedContext } : {}), - updatedAt: now, - }, - }) + .onConflictDoNothing() .returning() - return row + return row ?? getAsyncToolCall(input.toolCallId) } ) } @@ -399,6 +374,56 @@ export async function markAsyncToolRunning(toolCallId: string, claimedBy: string return markAsyncToolStatus(toolCallId, 'running', { claimedBy }) } +export function getClaimedWorkflowExecutionId(claimedBy: string | null | undefined) { + if (!claimedBy?.startsWith(WORKFLOW_EXECUTION_CLAIM_PREFIX)) return undefined + const executionId = claimedBy.slice(WORKFLOW_EXECUTION_CLAIM_PREFIX.length) + return executionId.length > 0 ? executionId : undefined +} + +export async function claimWorkflowToolExecution(toolCallId: string, executionId: string) { + const claimedBy = `${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}` + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolClaimedBy]: claimedBy, + }, + async () => { + const now = new Date() + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + status: sql`CASE WHEN ${copilotAsyncToolCalls.status} = ${ASYNC_TOOL_STATUS.pending} THEN ${ASYNC_TOOL_STATUS.running} ELSE ${copilotAsyncToolCalls.status} END`, + claimedBy, + claimedAt: now, + updatedAt: now, + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + isNull(copilotAsyncToolCalls.claimedBy), + or( + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.running, + ASYNC_TOOL_STATUS.delivered, + ]), + and( + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending), + inArray(copilotAsyncToolCalls.permissionDecision, [ + ...EXECUTABLE_TOOL_PERMISSION_DECISIONS, + ]) + ) + ) + ) + ) + .returning() + return row ?? null + } + ) +} + /** * Atomically claims a pending client tool exactly once. Native browser actions * use this before crossing the Electron boundary so a replayed renderer event @@ -461,14 +486,14 @@ export async function completeAsyncToolCall(input: { * continuing in the background. Whichever terminal or detach transition wins * is the only result eligible for publication. */ -export async function detachAsyncToolCall(toolCallId: string) { +export async function detachAsyncToolCall( + toolCallId: string, + options?: { preserveClaim?: boolean } +) { return markAsyncToolStatus( toolCallId, ASYNC_TOOL_STATUS.delivered, - { - claimedBy: null, - claimedAt: null, - }, + options?.preserveClaim ? {} : { claimedBy: null, claimedAt: null }, [ASYNC_TOOL_STATUS.pending, ASYNC_TOOL_STATUS.running] ) } @@ -507,11 +532,7 @@ export async function replaceTerminalAsyncToolCallResult(input: { .where( and( eq(copilotAsyncToolCalls.toolCallId, input.toolCallId), - inArray(copilotAsyncToolCalls.status, [ - ASYNC_TOOL_STATUS.completed, - ASYNC_TOOL_STATUS.failed, - ASYNC_TOOL_STATUS.cancelled, - ]) + eq(copilotAsyncToolCalls.status, input.status) ) ) .returning() @@ -553,7 +574,8 @@ export async function recordToolPermissionDecision( .where( and( eq(copilotAsyncToolCalls.toolCallId, toolCallId), - isNull(copilotAsyncToolCalls.permissionDecision) + isNull(copilotAsyncToolCalls.permissionDecision), + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending) ) ) .returning() diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index fd19458a712..292b1492232 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3492,7 +3492,7 @@ export const QueryUserTable: ToolCatalogEntry = { cursor: { type: 'string', description: - 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', }, filter: { type: 'object', @@ -3502,7 +3502,7 @@ export const QueryUserTable: ToolCatalogEntry = { limit: { type: 'number', description: - 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a non-null nextCursor in the result means more rows exist (continue with cursor).', + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', }, order: { type: 'array', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index c00fcd2cce6..421ddcadb25 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3151,7 +3151,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { cursor: { type: 'string', description: - 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', }, filter: { type: 'object', @@ -3161,7 +3161,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { limit: { type: 'number', description: - 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a non-null nextCursor in the result means more rows exist (continue with cursor).', + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', }, order: { type: 'array', diff --git a/apps/sim/lib/copilot/persistence/tool-confirm/index.ts b/apps/sim/lib/copilot/persistence/tool-confirm/index.ts index 09f5fd2ee93..dd1a41006b2 100644 --- a/apps/sim/lib/copilot/persistence/tool-confirm/index.ts +++ b/apps/sim/lib/copilot/persistence/tool-confirm/index.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { + ASYNC_TOOL_CONFIRMATION_STATUS, ASYNC_TOOL_STATUS, type AsyncCompletionEnvelope, type AsyncConfirmationState, @@ -46,10 +47,10 @@ export async function getToolConfirmation( }) if (!row) return null if (row.status === ASYNC_TOOL_STATUS.delivered) { - logger.warn('Delivered async tool rows are outside request confirmation flow', { - toolCallId, - }) - return null + return { + status: ASYNC_TOOL_CONFIRMATION_STATUS.background, + timestamp: row.updatedAt?.toISOString?.(), + } } return { status: diff --git a/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts b/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts index efe38c759ab..7b72bce8255 100644 --- a/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts +++ b/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts @@ -82,7 +82,7 @@ describe('copilot orchestrator persistence', () => { }) }) - it('ignores delivered rows in request confirmation flow', async () => { + it('reconstructs background from a delivered durable row', async () => { row = { status: 'delivered', result: { ok: true }, @@ -90,7 +90,10 @@ describe('copilot orchestrator persistence', () => { updatedAt: new Date('2026-01-01T00:00:00.000Z'), } - await expect(getToolConfirmation('tool-1')).resolves.toBeNull() + await expect(getToolConfirmation('tool-1')).resolves.toEqual({ + status: 'background', + timestamp: '2026-01-01T00:00:00.000Z', + }) }) it('ignores background when waiting for a foreground terminal status', async () => { @@ -163,4 +166,22 @@ describe('copilot orchestrator persistence', () => { timestamp: '2026-01-01T00:00:01.000Z', }) }) + + it('resolves background when detach completes before the waiter subscribes', async () => { + row = { + status: 'delivered', + error: null, + result: null, + updatedAt: new Date('2026-01-01T00:00:01.000Z'), + } + + await expect( + waitForToolConfirmation('tool-1', 5_000, undefined, { + acceptStatus: (status) => status === 'background', + }) + ).resolves.toEqual({ + status: 'background', + timestamp: '2026-01-01T00:00:01.000Z', + }) + }) }) diff --git a/apps/sim/lib/copilot/persistence/tool-permission/index.ts b/apps/sim/lib/copilot/persistence/tool-permission/index.ts index 718b6e9e3e6..083d11004d2 100644 --- a/apps/sim/lib/copilot/persistence/tool-permission/index.ts +++ b/apps/sim/lib/copilot/persistence/tool-permission/index.ts @@ -1,6 +1,7 @@ import type { CopilotToolPermissionDecision } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { isExecutableToolPermissionDecision } from '@/lib/copilot/async-runs/lifecycle' import { getAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' @@ -26,7 +27,7 @@ export interface ToolPermissionEnvelope { /** Every allow variant runs the tool; they differ only in what gets remembered. */ export function decisionAllowsExecution(decision: ToolPermissionDecision): boolean { - return decision !== TOOL_PERMISSION_DECISION.skip + return isExecutableToolPermissionDecision(decision) } /** True for the decisions that suppress future prompts for the same tool. */ diff --git a/apps/sim/lib/copilot/request/tools/client.test.ts b/apps/sim/lib/copilot/request/tools/client.test.ts index e993daef3ea..aa67e86e63b 100644 --- a/apps/sim/lib/copilot/request/tools/client.test.ts +++ b/apps/sim/lib/copilot/request/tools/client.test.ts @@ -140,7 +140,7 @@ describe('workflow client tool completion', () => { ) }) - it('fails closed when the bound execution or complete provenance is unavailable', async () => { + it('preserves the server-confirmed status while omitting unavailable execution content', async () => { const registry = createParentRegistry() waitForToolConfirmation.mockResolvedValue({ status: 'success', @@ -169,6 +169,98 @@ describe('workflow client tool completion', () => { expect(JSON.stringify(completion)).not.toContain('untrusted') }) + it('preserves cancellation when the bound terminal execution is not yet readable', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'cancelled', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(null) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'cancelled', + message: 'Workflow execution was cancelled.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + reason: 'user_cancelled', + cancelledByUser: true, + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('rejects a legacy success without a trusted execution identity', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', output: 'untrusted' }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { success: false, workflowId: 'workflow-1' }, + }) + expect(registry.isComplete()).toBe(false) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(JSON.stringify(completion)).not.toContain('untrusted') + }) + + it('uses the bound execution status when provenance is incomplete', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + ...trustedExecution('execution-1'), + status: 'failed', + error: 'trusted failure', + provenance: { + version: 1, + complete: false, + entries: [], + scope: TRACE_SCOPE, + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + it('imports and projects a secret activated only inside the child workflow', async () => { const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) waitForToolConfirmation.mockResolvedValue({ @@ -434,6 +526,75 @@ describe('generic client tool completion', () => { ) }) + it('does not invalidate later tool results while a sibling activation is pending', async () => { + const registry = createClientRegistry() + const firstContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValueOnce({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...firstContext, + }, + }) + + const finishSiblingActivation = registry.beginPendingActivation() + const first = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(first).toEqual({ status: 'success', message: 'Tool completed' }) + expect(registry.isPermanentlyIncomplete()).toBe(false) + finishSiblingActivation() + expect(registry.isComplete()).toBe(true) + + const secondContext = await sealClientToolContext({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValueOnce({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...secondContext, + }, + }) + + const second = await waitForClientToolCompletion({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(second).toEqual({ + status: 'success', + message: 'Tool completed', + data: { content: '{{SECRET}}' }, + }) + }) + it('fails structurally without an execution registry', async () => { waitForToolConfirmation.mockResolvedValue({ status: 'success', diff --git a/apps/sim/lib/copilot/request/tools/client.ts b/apps/sim/lib/copilot/request/tools/client.ts index 4a3f5b283a0..5dfc9f2b049 100644 --- a/apps/sim/lib/copilot/request/tools/client.ts +++ b/apps/sim/lib/copilot/request/tools/client.ts @@ -16,6 +16,7 @@ import { import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { createStructuralWorkflowToolCompletionData, + getWorkflowToolCompletionExecutionId, getWorkflowToolCompletionMessage, getWorkflowToolConfirmationStatus, } from '@/lib/copilot/tools/workflow-tools' @@ -83,25 +84,29 @@ export async function waitForClientToolCompletion({ const genericMessage = getGenericCompletionMessage(completion.status) const binding = runId ? { toolCallId, runId, userId } : undefined - const registryWasComplete = registry?.isComplete() === true + const registryCanImport = registry !== undefined && !registry.isPermanentlyIncomplete() const finishPendingActivation = registry?.beginPendingActivation() let content: Awaited> = null try { const [sealedContent, sealedContext] = - binding && registry && registryWasComplete + binding && registry && registryCanImport ? await Promise.all([ unsealClientToolCompletion(completion.data, binding), unsealClientToolContext(completion.data, binding, registry), ]) : [null, null] - if (!registry || !registryWasComplete || !sealedContent || !sealedContext) { - registry?.markIncomplete() - } else { - const imported = await registry.importProvenance(sealedContext.provenance, { trusted: true }) - if (!imported || !sealedContext.provenance.complete) { + if (registry && registryCanImport) { + if (!sealedContent || !sealedContext) { registry.markIncomplete() } else { - content = sealedContent + const imported = await registry.importProvenance(sealedContext.provenance, { + trusted: true, + }) + if (!imported || !sealedContext.provenance.complete) { + registry.markIncomplete() + } else { + content = sealedContent + } } } } catch { @@ -176,13 +181,6 @@ interface WaitForWorkflowToolCompletionOptions { registry?: ResolvedSecretTraceRegistry } -function getCompletionExecutionId(completion: AsyncTerminalCompletionSnapshot): string | undefined { - if (!isPlainRecord(completion.data)) return undefined - return typeof completion.data.executionId === 'string' && completion.data.executionId.length > 0 - ? completion.data.executionId - : undefined -} - function structuralWorkflowCompletion( status: AsyncTerminalCompletionSnapshot['status'], workflowId?: string, @@ -217,15 +215,19 @@ export async function waitForWorkflowToolCompletion({ return null } - const executionId = getCompletionExecutionId(completion) - if ( - completion.status === ASYNC_TOOL_CONFIRMATION_STATUS.background || - !workflowId || - !executionId - ) { + const executionId = getWorkflowToolCompletionExecutionId(completion.data) + if (completion.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { registry?.markIncomplete() return structuralWorkflowCompletion(completion.status, workflowId, executionId) } + if (!workflowId || !executionId) { + registry?.markIncomplete() + const structuralStatus = + completion.status === MothershipStreamV1ToolOutcome.success + ? MothershipStreamV1ToolOutcome.error + : completion.status + return structuralWorkflowCompletion(structuralStatus, workflowId, executionId) + } try { trustedExecution = await getTrustedWorkflowToolExecution(executionId, workflowId, toolCallId) @@ -238,12 +240,13 @@ export async function waitForWorkflowToolCompletion({ }) } - if (!trustedExecution || !trustedExecution.provenance.complete) { + if (!trustedExecution) { registry?.markIncomplete() return structuralWorkflowCompletion(completion.status, workflowId, executionId) } - if (!registry) { + if (!registry || registry.isPermanentlyIncomplete() || !trustedExecution.provenance.complete) { + if (!trustedExecution.provenance.complete) registry?.markIncomplete() return structuralWorkflowCompletion( getWorkflowToolConfirmationStatus(trustedExecution.status), workflowId, diff --git a/apps/sim/lib/copilot/tools/workflow-tools.ts b/apps/sim/lib/copilot/tools/workflow-tools.ts index ff346c71118..c7c6c103d92 100644 --- a/apps/sim/lib/copilot/tools/workflow-tools.ts +++ b/apps/sim/lib/copilot/tools/workflow-tools.ts @@ -28,6 +28,13 @@ export function resolveWorkflowToolTargetId( return typeof runWorkflowId === 'string' && runWorkflowId.length > 0 ? runWorkflowId : undefined } +export function getWorkflowToolCompletionExecutionId(data: unknown): string | undefined { + if (!isPlainRecord(data)) return undefined + return typeof data.executionId === 'string' && data.executionId.length > 0 + ? data.executionId + : undefined +} + export function getWorkflowToolCompletionMessage(status: AsyncConfirmationStatus): string { if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) { return 'Workflow execution completed.' From 586f21077654a6c55ea3cec926aa771723ca157e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 2 Aug 2026 00:57:13 -0700 Subject: [PATCH 6/8] fix(secrets): preserve own environment keys --- .../app/api/function/execute/route.test.ts | 20 +++++++++++++++++++ apps/sim/app/api/function/execute/route.ts | 9 +++++---- apps/sim/lib/api/contracts/hotspots.ts | 10 +++++++--- apps/sim/lib/api/contracts/primitives.ts | 16 +++++++++++++++ .../secret-mount-materializer.server.test.ts | 14 +++++++++++++ .../tools/secret-mount-materializer.server.ts | 3 ++- apps/sim/lib/core/utils/records.test.ts | 8 ++++++++ apps/sim/lib/core/utils/records.ts | 19 +++++++++++++++--- 8 files changed, 88 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index f23f0d06e07..6f2647f9e83 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -935,6 +935,26 @@ describe('Function Execute API Route', () => { expect((await response.json()).__resolvedSecretNames).toEqual(['ALLOWED']) }) + it('resolves a selected __proto__ secret as an own environment key', async () => { + const response = await POST( + createMockRequest( + 'POST', + { + code: 'return "{{__proto__}}"', + envVars: Object.fromEntries([['__proto__', 'secret-value']]), + secretScope: 'selected', + mountedSecrets: ['__proto__'], + }, + { + 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1', + } + ) + ) + + expect(response.status).toBe(200) + expect((await response.json()).__resolvedSecretNames).toEqual(['__proto__']) + }) + it.concurrent('should resolve tag variables with syntax', async () => { const req = createMockRequest('POST', { code: 'return ', diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 3204731376c..e6e35431b7c 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -17,6 +17,7 @@ import { writeWorkspaceFileByPath, } from '@/lib/copilot/vfs/resource-writer' import { isRemoteSandboxEnabled } from '@/lib/core/config/env-flags' +import { setRecordValue } from '@/lib/core/utils/records' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm' @@ -580,7 +581,7 @@ function scopeEnvironmentVariables( const scoped: Record = {} const missing: string[] = [] for (const name of allowed) { - if (name in envVars) scoped[name] = envVars[name] + if (Object.hasOwn(envVars, name)) setRecordValue(scoped, name, envVars[name]) else missing.push(name) } if (missing.length > 0) { @@ -608,19 +609,19 @@ function resolveEnvironmentVariables( const resolverVars: Record = {} Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { - resolverVars[key] = String(value) + setRecordValue(resolverVars, key, String(value)) } }) Object.entries(envVars).forEach(([key, value]) => { if (value !== undefined && value !== null) { - resolverVars[key] = value + setRecordValue(resolverVars, key, value) } }) while ((match = regex.exec(code)) !== null) { const varName = match[1].trim() - if (!(varName in resolverVars)) { + if (!Object.hasOwn(resolverVars, varName)) { continue } diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index eaa75f8f430..75acb8f86de 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { customPatternSchema, unknownRecordSchema } from '@/lib/api/contracts/primitives' +import { + customPatternSchema, + stringRecordSchema, + unknownRecordSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages' export const guardrailsValidateContract = defineRouteContract({ @@ -175,9 +179,9 @@ export const functionExecuteContract = defineRouteContract({ }) .strict() .optional(), - envVars: z.record(z.string(), z.string()).optional().default({}), + envVars: stringRecordSchema.optional().default({}), blockData: unknownRecordSchema.optional().default({}), - blockNameMapping: z.record(z.string(), z.string()).optional().default({}), + blockNameMapping: stringRecordSchema.optional().default({}), blockOutputSchemas: z.record(z.string(), unknownRecordSchema).optional().default({}), workflowVariables: unknownRecordSchema.optional().default({}), contextVariables: unknownRecordSchema.optional().default({}), diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index a0bfff57299..f899a27174f 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -1,9 +1,25 @@ +import { isPlainRecord } from '@sim/utils/object' import { z } from 'zod' +import { setRecordValue } from '@/lib/core/utils/records' import { PII_LANGUAGE_CODES, stripNerEntities } from '@/lib/guardrails/pii-entities' import { validateRegexPattern } from '@/lib/guardrails/validate_regex' export const unknownRecordSchema = z.record(z.string(), z.unknown()) +export const stringRecordSchema = z + .custom>( + (value) => + isPlainRecord(value) && Object.values(value).every((entry) => typeof entry === 'string'), + { error: 'Expected a record of string values' } + ) + .transform((value) => { + const record: Record = {} + for (const [key, entry] of Object.entries(value)) { + setRecordValue(record, key, entry) + } + return record + }) + export function flattenFieldErrors( error: z.ZodError ): Partial> { diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts index 6139a1b2b24..14313766c5d 100644 --- a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts @@ -112,6 +112,20 @@ describe('materializeCopilotCodeSecrets', () => { }) }) + it('mounts an own __proto__ secret as data without mutating record prototypes', async () => { + queueSources({ personal: Object.fromEntries([['__proto__', 'personal-cipher']]) }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['__proto__'], + }) + + expect(Object.hasOwn(result.envVars, '__proto__')).toBe(true) + expect(result.envVars.__proto__).toBe('plain:personal-cipher') + expect(Object.getPrototypeOf(result.envVars)).toBe(Object.prototype) + }) + it('casts stored JSON values before using JSONB operators', async () => { queueSources({ personal: { API_KEY: 'personal-cipher' } }) diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts index b8efbb4990c..47d81467c8f 100644 --- a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts @@ -7,6 +7,7 @@ import { MAX_SECRET_MOUNT_NAMES, } from '@/lib/copilot/secret-mount-policy' import { decryptSecret } from '@/lib/core/security/encryption' +import { setRecordValue } from '@/lib/core/utils/records' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import type { ResolvedSecretTraceCatalogEntry } from '@/executor/utils/resolved-secret-trace-registry' @@ -70,7 +71,7 @@ function encryptedVariables(row: { variables: unknown } | undefined): Record = {} for (const [name, value] of Object.entries(row.variables)) { - if (typeof value === 'string') result[name] = value + if (typeof value === 'string') setRecordValue(result, name, value) } return result } diff --git a/apps/sim/lib/core/utils/records.test.ts b/apps/sim/lib/core/utils/records.test.ts index 80195d71ccc..9bc22e8f07e 100644 --- a/apps/sim/lib/core/utils/records.test.ts +++ b/apps/sim/lib/core/utils/records.test.ts @@ -29,6 +29,14 @@ describe('record normalization utilities', () => { expect(normalizeStringRecord([])).toEqual({}) }) + it('preserves own __proto__ keys without changing the record prototype', () => { + const normalized = normalizeStringRecord(Object.fromEntries([['__proto__', 'secret-value']])) + + expect(Object.hasOwn(normalized, '__proto__')).toBe(true) + expect(normalized.__proto__).toBe('secret-value') + expect(Object.getPrototypeOf(normalized)).toBe(Object.prototype) + }) + it('normalizes record maps by dropping malformed entries', () => { expect( normalizeRecordMap({ diff --git a/apps/sim/lib/core/utils/records.ts b/apps/sim/lib/core/utils/records.ts index b13554b5c54..aea457c67d1 100644 --- a/apps/sim/lib/core/utils/records.ts +++ b/apps/sim/lib/core/utils/records.ts @@ -3,6 +3,15 @@ import { isPlainRecord } from '@sim/utils/object' export type UnknownRecord = Record export type StringRecord = Record +export function setRecordValue(record: Record, key: string, value: unknown): void { + Object.defineProperty(record, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }) +} + /** * Normalizes optional execution context maps to the record shape expected by * internal API contracts. @@ -25,7 +34,11 @@ export function normalizeStringRecord(value: unknown): StringRecord { if (entryValue === undefined || entryValue === null) { continue } - normalized[key] = typeof entryValue === 'string' ? entryValue : String(entryValue) + setRecordValue( + normalized, + key, + typeof entryValue === 'string' ? entryValue : String(entryValue) + ) } return normalized } @@ -41,7 +54,7 @@ export function normalizeRecordMap(value: unknown): Record = {} for (const [key, entryValue] of Object.entries(value)) { if (isPlainRecord(entryValue)) { - normalized[key] = entryValue + setRecordValue(normalized, key, entryValue) } } return normalized @@ -72,7 +85,7 @@ export function normalizeWorkflowVariables(value: unknown): UnknownRecord { const key = id ?? name if (key) { - normalized[key] = variable + setRecordValue(normalized, key, variable) } } From f9dddb4b14b4d378816f8123d3b65dedac81715f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 2 Aug 2026 01:12:23 -0700 Subject: [PATCH 7/8] fix(copilot): release failed workflow claims --- .../[id]/execute/route.async.test.ts | 39 +++++++++++++++++++ .../app/api/workflows/[id]/execute/route.ts | 19 ++++++++- .../lib/copilot/async-runs/repository.test.ts | 24 ++++++++++++ apps/sim/lib/copilot/async-runs/repository.ts | 34 ++++++++++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index f79075da804..6da3b5368fc 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -43,6 +43,7 @@ const { mockInitializeExecutionStreamMeta, mockReleaseExecutionIdClaim, mockReleaseExecutionSlot, + mockReleaseWorkflowToolExecutionClaim, mockRequireBillingAttributionHeader, mockValidatePublicApiAllowed, } = vi.hoisted(() => ({ @@ -67,6 +68,7 @@ const { mockInitializeExecutionStreamMeta: vi.fn(), mockReleaseExecutionIdClaim: vi.fn(), mockReleaseExecutionSlot: vi.fn(), + mockReleaseWorkflowToolExecutionClaim: vi.fn(), mockRequireBillingAttributionHeader: vi.fn(), mockValidatePublicApiAllowed: vi.fn(), })) @@ -119,6 +121,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ claimWorkflowToolExecution: mockClaimWorkflowToolExecution, getAsyncToolCall: mockGetAsyncToolCall, getRunSegment: mockGetRunSegment, + releaseWorkflowToolExecutionClaim: mockReleaseWorkflowToolExecutionClaim, })) vi.mock('@/lib/execution/event-buffer', () => ({ @@ -435,6 +438,7 @@ describe('workflow execute async route', () => { expect(response.status).toBe(200) expect(streamCompleted).toBe(false) expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') + expect(mockReleaseWorkflowToolExecutionClaim).not.toHaveBeenCalled() expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).toHaveBeenCalledWith({ executionId: 'execution-123', requestId: 'req-12345678', @@ -466,9 +470,44 @@ describe('workflow execute async route', () => { expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled() expect(mockPreprocessExecution).not.toHaveBeenCalled() expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + expect(mockReleaseWorkflowToolExecutionClaim).not.toHaveBeenCalled() expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() }) + it('releases a bound Copilot workflow claim when preprocessing rejects the run', async () => { + mockPreprocessExecution.mockResolvedValueOnce({ + success: false, + error: { message: 'Not admitted', statusCode: 402 }, + }) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(402) + expect(mockReleaseWorkflowToolExecutionClaim).toHaveBeenCalledWith( + 'copilot-tool-1', + 'execution-123' + ) + expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() + }) + + it('retains a bound Copilot workflow claim when preprocessing created a durable error log', async () => { + mockPreprocessExecution.mockResolvedValueOnce({ + success: false, + error: { message: 'Not admitted', statusCode: 402 }, + }) + mockHasDurableExecutionOwner.mockResolvedValueOnce(true) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(402) + expect(mockReleaseWorkflowToolExecutionClaim).not.toHaveBeenCalled() + expect(mockReleaseExecutionIdClaim).not.toHaveBeenCalled() + }) + it('binds a workflow execution after its page-hide confirmation detached the waiter', async () => { mockGetAsyncToolCall.mockResolvedValueOnce({ toolCallId: 'copilot-tool-1', diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 54f69a24f4a..73854085f0c 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -24,6 +24,7 @@ import { claimWorkflowToolExecution, getAsyncToolCall, getRunSegment, + releaseWorkflowToolExecutionClaim, } from '@/lib/copilot/async-runs/repository' import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' @@ -621,6 +622,8 @@ async function handleExecutePost( let executionId = '' let executionIdClaim: ExecutionIdClaim | null = null let executionIdClaimCommitted = false + let workflowToolClaimAcquired = false + let copilotToolCallId: string | undefined try { const auth = await checkHybridAuth(req, { requireWorkflowId: false }) @@ -758,13 +761,14 @@ async function handleExecutePost( workflowStateOverride, deploymentVersionId: admittedDeploymentVersionId, executionId: rawBodyExecutionId, - copilotToolCallId, + copilotToolCallId: parsedCopilotToolCallId, triggerBlockId: parsedTriggerBlockId, startBlockId, stopAfterBlockId, runFromBlock: rawRunFromBlock, parentWorkspaceId, } = validation.data + copilotToolCallId = parsedCopilotToolCallId const triggerBlockId = parsedTriggerBlockId ?? startBlockId const streamHeader = req.headers.get('X-Stream-Response') === 'true' const enableSSE = streamHeader || streamParam === true @@ -1160,6 +1164,7 @@ async function handleExecutePost( { status: 409 } ) } + workflowToolClaimAcquired = true } const loggingSession = new LoggingSession( @@ -2387,6 +2392,18 @@ async function handleExecutePost( } } + if (copilotToolCallId && workflowToolClaimAcquired && !executionIdClaimCommitted) { + try { + await releaseWorkflowToolExecutionClaim(copilotToolCallId, executionId) + } catch (error) { + reqLogger.warn('Failed to release pre-start Copilot workflow tool claim', { + error: toError(error).message, + executionId, + copilotToolCallId, + }) + } + } + if (executionIdClaim && !executionIdClaimCommitted) { try { await releaseExecutionIdClaim(executionIdClaim) diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index 03d1bda37b7..fcd9c01a4e7 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -12,6 +12,7 @@ import { detachAsyncToolCall, getClaimedWorkflowExecutionId, recordToolPermissionDecision, + releaseWorkflowToolExecutionClaim, replaceTerminalAsyncToolCallResult, upsertAsyncToolCall, } from './repository' @@ -161,6 +162,29 @@ describe('async tool repository single-row semantics', () => { await expect(claimWorkflowToolExecution('workflow-tool', 'execution-2')).resolves.toBeNull() }) + it('releases a matching pre-start workflow claim without changing its lifecycle status', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: null, + }, + ]) + + const result = await releaseWorkflowToolExecutionClaim('workflow-tool', 'execution-1') + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: null, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + claimedBy: null, + claimedAt: null, + updatedAt: expect.any(Date), + }) + }) + it('detaches a bound workflow waiter without releasing its execution claim', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 1c220c9a7a6..3497be4e987 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -424,6 +424,40 @@ export async function claimWorkflowToolExecution(toolCallId: string, executionId ) } +export async function releaseWorkflowToolExecutionClaim(toolCallId: string, executionId: string) { + const claimedBy = `${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}` + return withDbSpan( + TraceSpan.CopilotAsyncRunsReleaseClaim, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolClaimedBy]: claimedBy, + }, + async () => { + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + claimedBy: null, + claimedAt: null, + updatedAt: new Date(), + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + eq(copilotAsyncToolCalls.claimedBy, claimedBy), + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.running, + ASYNC_TOOL_STATUS.delivered, + ]) + ) + ) + .returning() + return row ?? null + } + ) +} + /** * Atomically claims a pending client tool exactly once. Native browser actions * use this before crossing the Electron boundary so a replayed renderer event From ea06a77388e36031bcd80aa648226b27084882eb Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 2 Aug 2026 01:30:30 -0700 Subject: [PATCH 8/8] fix(copilot): trust compacted workflow completion --- .../sim/app/api/copilot/confirm/route.test.ts | 9 ++- .../lib/copilot/request/tools/client.test.ts | 36 ++++++++++ apps/sim/lib/copilot/request/tools/client.ts | 9 +++ apps/sim/lib/logs/execution/logger.test.ts | 36 ++++++++++ apps/sim/lib/logs/execution/logger.ts | 1 + .../lib/logs/execution/trace-store.test.ts | 65 ++++++++++++++++++- apps/sim/lib/logs/execution/trace-store.ts | 11 ++-- .../executor/execution-state.test.ts | 61 ++++++++++++++--- .../lib/workflows/executor/execution-state.ts | 27 +++++++- 9 files changed, 232 insertions(+), 23 deletions(-) diff --git a/apps/sim/app/api/copilot/confirm/route.test.ts b/apps/sim/app/api/copilot/confirm/route.test.ts index b618acaf57b..a97acc3a85f 100644 --- a/apps/sim/app/api/copilot/confirm/route.test.ts +++ b/apps/sim/app/api/copilot/confirm/route.test.ts @@ -910,13 +910,18 @@ describe('Copilot Confirm API Route', () => { }) }) - it('derives workflow outcome from the terminal server execution', async () => { + it('derives workflow outcome from a content-unavailable trusted terminal execution', async () => { getAsyncToolCall.mockResolvedValue({ ...existingRow, toolName: 'run_workflow', args: { workflowId: 'workflow-1' }, }) - getTrustedWorkflowToolExecution.mockResolvedValueOnce({ status: 'failed' }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'failed', + contentAvailable: false, + }) const response = await POST( createMockPostRequest({ diff --git a/apps/sim/lib/copilot/request/tools/client.test.ts b/apps/sim/lib/copilot/request/tools/client.test.ts index aa67e86e63b..16e798fe1ea 100644 --- a/apps/sim/lib/copilot/request/tools/client.test.ts +++ b/apps/sim/lib/copilot/request/tools/client.test.ts @@ -79,6 +79,7 @@ function trustedExecution(executionId: string) { executionId, workflowId: 'workflow-1', status: 'completed' as const, + contentAvailable: true as const, finalOutput: { value: `child read parent-secret-value from ${executionId}` }, blockLogs: [], provenance: { @@ -169,6 +170,39 @@ describe('workflow client tool completion', () => { expect(JSON.stringify(completion)).not.toContain('untrusted') }) + it('uses compacted terminal status without exposing unavailable execution content', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'failed', + contentAvailable: false, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + it('preserves cancellation when the bound terminal execution is not yet readable', async () => { const registry = createParentRegistry() waitForToolConfirmation.mockResolvedValue({ @@ -271,6 +305,7 @@ describe('workflow client tool completion', () => { executionId: 'execution-1', workflowId: 'workflow-1', status: 'completed', + contentAvailable: true, finalOutput: { value: 'child-secret-value' }, blockLogs: [], provenance: { @@ -309,6 +344,7 @@ describe('workflow client tool completion', () => { executionId: 'execution-1', workflowId: 'workflow-1', status: 'failed', + contentAvailable: true, error: 'trusted failure', blockLogs: [], provenance: { version: 1, complete: true, entries: [], scope: TRACE_SCOPE }, diff --git a/apps/sim/lib/copilot/request/tools/client.ts b/apps/sim/lib/copilot/request/tools/client.ts index 5dfc9f2b049..f6c7ebede0f 100644 --- a/apps/sim/lib/copilot/request/tools/client.ts +++ b/apps/sim/lib/copilot/request/tools/client.ts @@ -245,6 +245,15 @@ export async function waitForWorkflowToolCompletion({ return structuralWorkflowCompletion(completion.status, workflowId, executionId) } + if (!trustedExecution.contentAvailable) { + registry?.markIncomplete() + return structuralWorkflowCompletion( + getWorkflowToolConfirmationStatus(trustedExecution.status), + workflowId, + executionId + ) + } + if (!registry || registry.isPermanentlyIncomplete() || !trustedExecution.provenance.complete) { if (!trustedExecution.provenance.complete) registry?.markIncomplete() return structuralWorkflowCompletion( diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index f79f83d915d..ef73b1c11b6 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -380,6 +380,42 @@ describe('ExecutionLogger', () => { expect(compacted.traceSpans?.[0]?.children?.[0]).not.toHaveProperty('input') }) + test('retains the trusted Copilot binding in metadata-only compaction', () => { + const loggerInstance = new ExecutionLogger() as unknown as { + compactExecutionDataForStorage( + executionData: WorkflowExecutionLog['executionData'], + executionId: string + ): WorkflowExecutionLog['executionData'] + } + const correlation = { + executionId: 'execution-metadata-only', + requestId: 'request-1', + source: 'workflow' as const, + workflowId: 'workflow-1', + copilotToolCallId: 'tool-call-1', + } + + const compacted = loggerInstance.compactExecutionDataForStorage( + { + environment: { + variables: { OVERSIZED: 'x'.repeat(3.5 * 1024 * 1024) }, + workflowId: 'workflow-1', + executionId: 'execution-metadata-only', + userId: 'user-1', + workspaceId: 'workspace-1', + }, + correlation, + hasTraceSpans: false, + traceSpanCount: 0, + }, + 'execution-metadata-only' + ) + + expect(compacted.executionDataTruncated).toBe(true) + expect(compacted.correlation).toEqual(correlation) + expect(compacted).not.toHaveProperty('environment') + }) + test('retains tool-call structure when aggregate trace content exceeds the compaction cap', () => { const loggerInstance = new ExecutionLogger() as unknown as { compactExecutionDataForStorage( diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 897275d78f8..5394919c439 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -491,6 +491,7 @@ export class ExecutionLogger implements IExecutionLoggerService { ...(executionData.billingAttribution ? { billingAttribution: executionData.billingAttribution } : {}), + ...(executionData.correlation ? { correlation: executionData.correlation } : {}), hasTraceSpans: executionData.hasTraceSpans, traceSpanCount: executionData.traceSpanCount, tokens: executionData.tokens, diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index b1341811878..5655bbe877b 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -3,15 +3,27 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { decryptSecretMock } = vi.hoisted(() => ({ +const { decryptSecretMock, materializeLargeValueRefMock, storeLargeValueMock } = vi.hoisted(() => ({ decryptSecretMock: vi.fn(), + materializeLargeValueRefMock: vi.fn(), + storeLargeValueMock: vi.fn(), })) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: decryptSecretMock, })) -import { projectExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +vi.mock('@/lib/execution/payloads/store', () => ({ + materializeLargeValueRef: materializeLargeValueRefMock, + storeLargeValue: storeLargeValueMock, +})) + +import { + externalizeExecutionData, + materializeExecutionData, + projectExecutionDataForDisplay, + TRACE_STORE_REF_KEY, +} from '@/lib/logs/execution/trace-store' const CONTEXT = { workspaceId: 'workspace-1', @@ -25,6 +37,55 @@ beforeEach(() => { decryptSecretMock.mockResolvedValue({ decrypted: '1234' }) }) +describe('execution data storage', () => { + it('keeps the trusted Copilot binding when an externalized payload is unavailable', async () => { + const correlation = { copilotToolCallId: 'tool-call-1' } + const ref = { + __simLargeValueRef: true, + version: 1, + id: 'lv_bbbbbbbbbbbb', + kind: 'object', + size: 128, + key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_bbbbbbbbbbbb.json', + executionId: 'execution-1', + preview: { unsafe: 'must-not-remain-inline' }, + } as const + storeLargeValueMock.mockResolvedValue(ref) + materializeLargeValueRefMock.mockRejectedValue(new Error('object unavailable')) + + const slim = await externalizeExecutionData( + { + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + finalOutput: { unsafe: 'must-not-remain-inline' }, + }, + CONTEXT + ) + + expect(slim).toEqual({ + [TRACE_STORE_REF_KEY]: { + __simLargeValueRef: true, + version: 1, + id: 'lv_bbbbbbbbbbbb', + kind: 'object', + size: 128, + key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_bbbbbbbbbbbb.json', + executionId: 'execution-1', + }, + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + }) + + await expect(materializeExecutionData(slim, CONTEXT)).resolves.toEqual({ + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + }) + }) +}) + describe('projectExecutionDataForDisplay', () => { it('projects persisted output, input, errors, and spans from trusted provenance', async () => { const executionData = { diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index 16bf3b6cd75..f429bade600 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -20,14 +20,13 @@ export const TRACE_STORE_REF_KEY = 'traceStoreRef' /** * The only metadata kept inline on the slim row (everything else lives in the - * externalized object). These two describe trace presence/count and uniquely - * survive object expiry — so a reader can still report "trace data expired (N - * spans)" after retention without an object fetch. All other fields + * externalized object). Trace presence/count survives object expiry for log + * diagnostics, while correlation preserves the server-issued binding used to + * authenticate terminal Copilot workflow-tool executions. All other fields * (environment, trigger, tokens, models, truncation flags, and of course the - * heavy payloads) are in the stored object and recovered on materialize, so - * keeping them inline too would just be duplication. + * heavy payloads) are recovered from the stored object. */ -const INLINE_MARKER_KEYS = ['hasTraceSpans', 'traceSpanCount'] as const +const INLINE_MARKER_KEYS = ['hasTraceSpans', 'traceSpanCount', 'correlation'] as const /** * Read-path context. Resolves an externalized payload by storage key, authorized diff --git a/apps/sim/lib/workflows/executor/execution-state.test.ts b/apps/sim/lib/workflows/executor/execution-state.test.ts index 4cbb61c5efb..ae72a5e218f 100644 --- a/apps/sim/lib/workflows/executor/execution-state.test.ts +++ b/apps/sim/lib/workflows/executor/execution-state.test.ts @@ -104,6 +104,7 @@ describe('execution state lookup', () => { executionId: 'execution-1', workflowId: 'workflow-1', status: 'completed', + contentAvailable: true, finalOutput: { token: 'raw-secret' }, blockLogs: [], provenance, @@ -157,7 +158,7 @@ describe('execution state lookup', () => { }) }) - it('rejects mismatched bindings, malformed provenance, and nonterminal rows', async () => { + it('trusts compacted terminal status while withholding unavailable execution content', async () => { queueTableRows(schemaMock.workflowExecutionLogs, [ { executionId: 'execution-1', @@ -168,20 +169,31 @@ describe('execution state lookup', () => { }, ]) mockMaterializeExecutionData.mockResolvedValueOnce({ - correlation: { copilotToolCallId: 'another-tool-call' }, - executionState: { - ...EXECUTION_STATE, - resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] }, + correlation: { copilotToolCallId: 'tool-call-1' }, + executionStateSummary: { + executedBlockCount: 1, + blockLogCount: 1, + completedLoopCount: 0, + activeExecutionPathLength: 0, + pendingQueueLength: 0, }, + finalOutput: { token: 'must-not-cross' }, }) await expect( getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') - ).resolves.toBeNull() + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: false, + }) + }) + it('withholds execution content when persisted provenance is malformed', async () => { queueTableRows(schemaMock.workflowExecutionLogs, [ { - executionId: 'execution-2', + executionId: 'execution-1', workflowId: 'workflow-1', workspaceId: 'workspace-1', status: 'completed', @@ -190,6 +202,7 @@ describe('execution state lookup', () => { ]) mockMaterializeExecutionData.mockResolvedValueOnce({ correlation: { copilotToolCallId: 'tool-call-1' }, + finalOutput: { token: 'must-not-cross' }, executionState: { ...EXECUTION_STATE, resolvedSecretTraceProvenance: { version: 2, complete: true, entries: [] }, @@ -197,12 +210,40 @@ describe('execution state lookup', () => { }) await expect( - getTrustedWorkflowToolExecution('execution-2', 'workflow-1', 'tool-call-1') + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: false, + }) + }) + + it('rejects mismatched bindings and nonterminal rows', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'another-tool-call' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') ).resolves.toBeNull() queueTableRows(schemaMock.workflowExecutionLogs, [ { - executionId: 'execution-3', + executionId: 'execution-2', workflowId: 'workflow-1', workspaceId: 'workspace-1', status: 'running', @@ -211,7 +252,7 @@ describe('execution state lookup', () => { ]) await expect( - getTrustedWorkflowToolExecution('execution-3', 'workflow-1', 'tool-call-1') + getTrustedWorkflowToolExecution('execution-2', 'workflow-1', 'tool-call-1') ).resolves.toBeNull() }) diff --git a/apps/sim/lib/workflows/executor/execution-state.ts b/apps/sim/lib/workflows/executor/execution-state.ts index 945accbb7fb..4f0b8eacfbf 100644 --- a/apps/sim/lib/workflows/executor/execution-state.ts +++ b/apps/sim/lib/workflows/executor/execution-state.ts @@ -62,16 +62,29 @@ interface ExecutionStateRow { executionData: unknown } -export interface TrustedWorkflowToolExecution { +interface TrustedWorkflowToolExecutionBase { executionId: string workflowId: string status: 'completed' | 'failed' | 'cancelled' +} + +export interface TrustedWorkflowToolExecutionWithoutContent + extends TrustedWorkflowToolExecutionBase { + contentAvailable: false +} + +export interface TrustedWorkflowToolExecutionWithContent extends TrustedWorkflowToolExecutionBase { + contentAvailable: true finalOutput?: unknown error?: string blockLogs: SerializableExecutionState['blockLogs'] provenance: ResolvedSecretTraceProvenanceV1 } +export type TrustedWorkflowToolExecution = + | TrustedWorkflowToolExecutionWithoutContent + | TrustedWorkflowToolExecutionWithContent + async function getExecutionStateRow( executionId: string, workflowId: string @@ -152,18 +165,26 @@ export async function getTrustedWorkflowToolExecution( if ( !executionData || - !state || - !isResolvedSecretTraceProvenanceV1(provenance) || !isRecordLike(correlation) || correlation.copilotToolCallId !== copilotToolCallId ) { return null } + if (!state || !isResolvedSecretTraceProvenanceV1(provenance)) { + return { + executionId, + workflowId, + status: row.status, + contentAvailable: false, + } + } + return { executionId, workflowId, status: row.status, + contentAvailable: true, ...(Object.hasOwn(executionData, 'finalOutput') ? { finalOutput: executionData.finalOutput } : {}),