diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 8fef0d4ed0..4b7b9ff5d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -58,12 +58,16 @@ import { WorkspaceFile, WorkspaceFileOperation, } from '@/lib/copilot/generated/tool-catalog-v1' -import { parsePersistedStreamEventEnvelopeJson } from '@/lib/copilot/request/session/contract' +import { + type ParseStreamEventEnvelopeFailure, + parsePersistedStreamEventEnvelope, + parsePersistedStreamEventEnvelopeJson, +} from '@/lib/copilot/request/session/contract' import { type FilePreviewSession, isFilePreviewSession, } from '@/lib/copilot/request/session/file-preview-session-contract' -import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/copilot/request/session/types' +import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' import { extractResourcesFromToolResult, isResourceToolName, @@ -509,6 +513,33 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } +const STREAM_SCHEMA_ENFORCEMENT_PREFIX = 'Client stream schema enforcement failed.' + +class StreamSchemaValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'StreamSchemaValidationError' + } +} + +function createStreamSchemaValidationError( + failure: ParseStreamEventEnvelopeFailure, + context?: string +): StreamSchemaValidationError { + const details = failure.errors?.filter(Boolean).join('; ') + return new StreamSchemaValidationError( + [STREAM_SCHEMA_ENFORCEMENT_PREFIX, context, failure.message, details].filter(Boolean).join(' ') + ) +} + +function createBatchSchemaValidationError(message: string): StreamSchemaValidationError { + return new StreamSchemaValidationError([STREAM_SCHEMA_ENFORCEMENT_PREFIX, message].join(' ')) +} + +function isStreamSchemaValidationError(error: unknown): error is StreamSchemaValidationError { + return error instanceof StreamSchemaValidationError +} + function parseStreamBatchResponse(value: unknown): StreamBatchResponse { if (!isRecord(value)) { throw new Error('Invalid stream batch response') @@ -516,20 +547,41 @@ function parseStreamBatchResponse(value: unknown): StreamBatchResponse { const rawEvents = Array.isArray(value.events) ? value.events : [] const events: StreamBatchEvent[] = [] - for (const entry of rawEvents) { - if (!isStreamBatchEvent(entry)) { - throw new Error('Invalid stream batch event') + for (const [index, entry] of rawEvents.entries()) { + if (!isRecord(entry)) { + throw createBatchSchemaValidationError(`Reconnect batch event ${index + 1} is not an object.`) + } + if ( + typeof entry.eventId !== 'number' || + !Number.isFinite(entry.eventId) || + typeof entry.streamId !== 'string' + ) { + throw createBatchSchemaValidationError( + `Reconnect batch event ${index + 1} is missing required metadata.` + ) } - events.push(entry) + + const parsedEvent = parsePersistedStreamEventEnvelope(entry.event) + if (!parsedEvent.ok) { + throw createStreamSchemaValidationError(parsedEvent, `Reconnect batch event ${index + 1}.`) + } + + events.push({ + eventId: entry.eventId, + streamId: entry.streamId, + event: parsedEvent.event, + }) } const rawPreviewSessions = Array.isArray(value.previewSessions) ? value.previewSessions : undefined const previewSessions = - rawPreviewSessions?.map((session) => { + rawPreviewSessions?.map((session, index) => { if (!isFilePreviewSession(session)) { - throw new Error('Invalid stream preview session') + throw createBatchSchemaValidationError( + `Reconnect preview session ${index + 1} failed validation.` + ) } return session }) ?? undefined @@ -1579,12 +1631,14 @@ export function useChat( const parsedResult = parsePersistedStreamEventEnvelopeJson(raw) if (!parsedResult.ok) { - logger.warn('Failed to parse chat SSE event', { + const error = createStreamSchemaValidationError(parsedResult, 'Live SSE event.') + logger.error('Rejected chat SSE event due to client-side schema enforcement', { reason: parsedResult.reason, message: parsedResult.message, errors: parsedResult.errors, + error: error.message, }) - continue + throw error } const parsed = parsedResult.event @@ -2533,6 +2587,17 @@ export function useChat( } return true } + if (isStreamSchemaValidationError(err)) { + logger.error('Reconnect halted by client-side stream schema enforcement', { + streamId, + attempt: attempt + 1, + error: err.message, + }) + if (streamGenRef.current === gen) { + setError(err.message) + } + return false + } logger.warn('Reconnect attempt failed', { streamId, attempt: attempt + 1, @@ -2892,6 +2957,13 @@ export function useChat( } } catch (err) { if (err instanceof Error && err.name === 'AbortError') return consumedByTranscript + if (isStreamSchemaValidationError(err)) { + setError(err.message) + if (streamGenRef.current === gen) { + finalize({ error: true }) + } + return consumedByTranscript + } const activeStreamId = streamIdRef.current if (activeStreamId && streamGenRef.current === gen) { diff --git a/apps/sim/lib/copilot/request/session/contract.test.ts b/apps/sim/lib/copilot/request/session/contract.test.ts index 7b76aec22b..e9ac58707c 100644 --- a/apps/sim/lib/copilot/request/session/contract.test.ts +++ b/apps/sim/lib/copilot/request/session/contract.test.ts @@ -43,6 +43,92 @@ describe('stream session contract parser', () => { }) }) + it('accepts contract session chat events', () => { + const event = { + ...BASE_ENVELOPE, + type: 'session' as const, + payload: { kind: 'chat' as const, chatId: 'chat-1' }, + } + + expect(isContractStreamEventEnvelope(event)).toBe(true) + expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true) + }) + + it('accepts contract complete events', () => { + const event = { + ...BASE_ENVELOPE, + type: 'complete' as const, + payload: { status: 'complete' as const }, + } + + expect(isContractStreamEventEnvelope(event)).toBe(true) + expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true) + }) + + it('accepts contract error events', () => { + const event = { + ...BASE_ENVELOPE, + type: 'error' as const, + payload: { message: 'something went wrong' }, + } + + expect(isContractStreamEventEnvelope(event)).toBe(true) + expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true) + }) + + it('accepts contract tool call events', () => { + const event = { + ...BASE_ENVELOPE, + type: 'tool' as const, + payload: { + toolCallId: 'tc-1', + toolName: 'read', + phase: 'call' as const, + executor: 'sim' as const, + mode: 'sync' as const, + }, + } + + expect(isContractStreamEventEnvelope(event)).toBe(true) + expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true) + }) + + it('accepts contract span events', () => { + const event = { + ...BASE_ENVELOPE, + type: 'span' as const, + payload: { kind: 'subagent' as const, event: 'start' as const, agent: 'file' }, + } + + expect(isContractStreamEventEnvelope(event)).toBe(true) + expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true) + }) + + it('accepts contract resource events', () => { + const event = { + ...BASE_ENVELOPE, + type: 'resource' as const, + payload: { + op: 'upsert' as const, + resource: { id: 'r-1', type: 'file', title: 'test.md' }, + }, + } + + expect(isContractStreamEventEnvelope(event)).toBe(true) + expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true) + }) + + it('accepts contract run events', () => { + const event = { + ...BASE_ENVELOPE, + type: 'run' as const, + payload: { kind: 'compaction_start' as const }, + } + + expect(isContractStreamEventEnvelope(event)).toBe(true) + expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true) + }) + it('accepts synthetic file preview events', () => { const event = { ...BASE_ENVELOPE, @@ -82,7 +168,32 @@ describe('stream session contract parser', () => { throw new Error('expected invalid result') } expect(parsed.reason).toBe('invalid_stream_event') - expect(parsed.errors?.length).toBeGreaterThan(0) + }) + + it('rejects unknown event types', () => { + const parsed = parsePersistedStreamEventEnvelope({ + ...BASE_ENVELOPE, + type: 'unknown_type', + payload: {}, + }) + + expect(parsed.ok).toBe(false) + if (parsed.ok) { + throw new Error('expected invalid result') + } + expect(parsed.reason).toBe('invalid_stream_event') + expect(parsed.errors).toContain('unknown type="unknown_type"') + }) + + it('rejects non-object values', () => { + const parsed = parsePersistedStreamEventEnvelope('not an object') + + expect(parsed.ok).toBe(false) + if (parsed.ok) { + throw new Error('expected invalid result') + } + expect(parsed.reason).toBe('invalid_stream_event') + expect(parsed.errors).toContain('value is not an object') }) it('reports invalid JSON separately from schema failures', () => { diff --git a/apps/sim/lib/copilot/request/session/contract.ts b/apps/sim/lib/copilot/request/session/contract.ts index 3a592dc707..ff45dbd915 100644 --- a/apps/sim/lib/copilot/request/session/contract.ts +++ b/apps/sim/lib/copilot/request/session/contract.ts @@ -1,21 +1,22 @@ -import type { ErrorObject, ValidateFunction } from 'ajv' -import Ajv2020 from 'ajv/dist/2020.js' import type { MothershipStreamV1EventEnvelope, MothershipStreamV1StreamRef, MothershipStreamV1StreamScope, MothershipStreamV1Trace, } from '@/lib/copilot/generated/mothership-stream-v1' -import { MOTHERSHIP_STREAM_V1_SCHEMA } from '@/lib/copilot/generated/mothership-stream-v1-schema' +import { + MothershipStreamV1EventType, + MothershipStreamV1ResourceOp, + MothershipStreamV1RunKind, + MothershipStreamV1SessionKind, + MothershipStreamV1SpanPayloadKind, + MothershipStreamV1TextChannel, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' import type { FilePreviewTargetKind } from './file-preview-session-contract' type JsonRecord = Record -const ajv = new Ajv2020({ - allErrors: true, - strict: false, -}) - const FILE_PREVIEW_PHASE = { start: 'file_preview_start', target: 'file_preview_target', @@ -144,26 +145,9 @@ export type ParseStreamEventEnvelopeResult = | ParseStreamEventEnvelopeSuccess | ParseStreamEventEnvelopeFailure -let validator: ValidateFunction | null = null - -function getValidator(): ValidateFunction { - if (validator) { - return validator - } - - validator = ajv.compile(MOTHERSHIP_STREAM_V1_SCHEMA as object) - return validator -} - -function formatValidationErrors(errors: ErrorObject[] | null | undefined): string[] | undefined { - if (!errors || errors.length === 0) { - return undefined - } - - return errors - .slice(0, 5) - .map((error) => `${error.instancePath || '/'} ${error.message || 'is invalid'}`.trim()) -} +// --------------------------------------------------------------------------- +// Structural helpers (CSP-safe – no codegen / eval / new Function) +// --------------------------------------------------------------------------- function isRecord(value: unknown): value is JsonRecord { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) @@ -199,6 +183,140 @@ function isStreamScope(value: unknown): value is MothershipStreamV1StreamScope { ) } +// --------------------------------------------------------------------------- +// Contract envelope validator (replaces Ajv runtime compilation) +// +// Validates the envelope shell (v, seq, ts, stream, trace?, scope?) and that +// `type` is one of the known event types with a non-null payload object. +// Per-payload-variant validation is intentionally lightweight: the server +// already performs strict schema validation; the client only needs enough +// structural checking to safely dispatch inside the switch statement. +// --------------------------------------------------------------------------- + +const KNOWN_EVENT_TYPES: ReadonlySet = new Set(Object.values(MothershipStreamV1EventType)) + +function isValidEnvelopeShell(value: unknown): value is JsonRecord & { + v: 1 + seq: number + ts: string + stream: MothershipStreamV1StreamRef + type: string + payload: JsonRecord +} { + if (!isRecord(value)) return false + if (value.v !== 1) return false + if (typeof value.seq !== 'number' || !Number.isFinite(value.seq)) return false + if (typeof value.ts !== 'string') return false + if (!isStreamRef(value.stream)) return false + if (value.trace !== undefined && !isTrace(value.trace)) return false + if (value.scope !== undefined && !isStreamScope(value.scope)) return false + if (typeof value.type !== 'string' || !KNOWN_EVENT_TYPES.has(value.type)) return false + if (!isRecord(value.payload)) return false + return true +} + +function isValidSessionPayload(payload: JsonRecord): boolean { + const kind = payload.kind + if (typeof kind !== 'string') return false + switch (kind) { + case MothershipStreamV1SessionKind.start: + return true + case MothershipStreamV1SessionKind.chat: + return typeof payload.chatId === 'string' + case MothershipStreamV1SessionKind.title: + return typeof payload.title === 'string' + case MothershipStreamV1SessionKind.trace: + return typeof payload.requestId === 'string' + default: + return false + } +} + +function isValidTextPayload(payload: JsonRecord): boolean { + return ( + (payload.channel === MothershipStreamV1TextChannel.assistant || + payload.channel === MothershipStreamV1TextChannel.thinking) && + typeof payload.text === 'string' + ) +} + +function isValidToolPayload(payload: JsonRecord): boolean { + if (typeof payload.toolCallId !== 'string') return false + if (typeof payload.toolName !== 'string') return false + const phase = payload.phase + return ( + phase === MothershipStreamV1ToolPhase.call || + phase === MothershipStreamV1ToolPhase.args_delta || + phase === MothershipStreamV1ToolPhase.result + ) +} + +function isValidSpanPayload(payload: JsonRecord): boolean { + const kind = payload.kind + return ( + kind === MothershipStreamV1SpanPayloadKind.subagent || + kind === MothershipStreamV1SpanPayloadKind.structured_result || + kind === MothershipStreamV1SpanPayloadKind.subagent_result + ) +} + +function isValidResourcePayload(payload: JsonRecord): boolean { + return ( + (payload.op === MothershipStreamV1ResourceOp.upsert || + payload.op === MothershipStreamV1ResourceOp.remove) && + isRecord(payload.resource) && + typeof (payload.resource as JsonRecord).id === 'string' && + typeof (payload.resource as JsonRecord).type === 'string' + ) +} + +function isValidRunPayload(payload: JsonRecord): boolean { + const kind = payload.kind + return ( + kind === MothershipStreamV1RunKind.checkpoint_pause || + kind === MothershipStreamV1RunKind.resumed || + kind === MothershipStreamV1RunKind.compaction_start || + kind === MothershipStreamV1RunKind.compaction_done + ) +} + +function isValidErrorPayload(payload: JsonRecord): boolean { + return typeof payload.message === 'string' || typeof payload.error === 'string' +} + +function isValidCompletePayload(payload: JsonRecord): boolean { + return typeof payload.status === 'string' +} + +function isContractEnvelope(value: unknown): value is MothershipStreamV1EventEnvelope { + if (!isValidEnvelopeShell(value)) return false + const payload = value.payload as JsonRecord + switch (value.type) { + case MothershipStreamV1EventType.session: + return isValidSessionPayload(payload) + case MothershipStreamV1EventType.text: + return isValidTextPayload(payload) + case MothershipStreamV1EventType.tool: + return isValidToolPayload(payload) + case MothershipStreamV1EventType.span: + return isValidSpanPayload(payload) + case MothershipStreamV1EventType.resource: + return isValidResourcePayload(payload) + case MothershipStreamV1EventType.run: + return isValidRunPayload(payload) + case MothershipStreamV1EventType.error: + return isValidErrorPayload(payload) + case MothershipStreamV1EventType.complete: + return isValidCompletePayload(payload) + default: + return false + } +} + +// --------------------------------------------------------------------------- +// Synthetic file-preview envelope validators +// --------------------------------------------------------------------------- + function isSyntheticEnvelopeBase( value: unknown ): value is Omit & { payload?: unknown } { @@ -269,6 +387,10 @@ export function isSyntheticFilePreviewEventEnvelope( return isSyntheticEnvelopeBase(value) && isSyntheticFilePreviewPayload(value.payload) } +// --------------------------------------------------------------------------- +// Stream event type guards +// --------------------------------------------------------------------------- + export function isToolCallStreamEvent(event: SessionStreamEvent): event is ToolCallStreamEvent { return event.type === 'tool' && isRecord(event.payload) && event.payload.phase === 'call' } @@ -289,33 +411,40 @@ export function isSubagentSpanStreamEvent( return event.type === 'span' && isRecord(event.payload) && event.payload.kind === 'subagent' } +// --------------------------------------------------------------------------- +// Public contract validators & parsers +// --------------------------------------------------------------------------- + export function isContractStreamEventEnvelope( value: unknown ): value is MothershipStreamV1EventEnvelope { - return getValidator()(value) + return isContractEnvelope(value) } export function parsePersistedStreamEventEnvelope(value: unknown): ParseStreamEventEnvelopeResult { - const envelopeValidator = getValidator() - if (envelopeValidator(value)) { - return { - ok: true, - event: value, - } + if (isContractEnvelope(value)) { + return { ok: true, event: value } } if (isSyntheticFilePreviewEventEnvelope(value)) { - return { - ok: true, - event: value, - } + return { ok: true, event: value } + } + + const hints: string[] = [] + if (!isRecord(value)) { + hints.push('value is not an object') + } else { + if (value.v !== 1) hints.push(`unexpected v=${JSON.stringify(value.v)}`) + if (typeof value.type !== 'string') hints.push('missing type') + else if (!KNOWN_EVENT_TYPES.has(value.type)) hints.push(`unknown type="${value.type}"`) + if (!isRecord(value.payload)) hints.push('missing or invalid payload') } return { ok: false, reason: 'invalid_stream_event', - message: 'Stream event failed validation', - errors: formatValidationErrors(envelopeValidator.errors), + message: 'A stream event failed validation.', + ...(hints.length > 0 ? { errors: hints } : {}), } } @@ -324,10 +453,12 @@ export function parsePersistedStreamEventEnvelopeJson(raw: string): ParseStreamE try { parsed = JSON.parse(raw) } catch (error) { + const rawMessage = error instanceof Error ? error.message : 'Invalid JSON' return { ok: false, reason: 'invalid_json', - message: error instanceof Error ? error.message : 'Invalid JSON', + message: 'Received invalid JSON while parsing a stream event.', + ...(rawMessage ? { errors: [rawMessage] } : {}), } } diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 0a0e4bf0aa..e0c0576880 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -752,7 +752,7 @@ export function serializeIntegrationSchema(tool: ToolConfig): string { type: 'string', required: false, description: - 'Optional credential ID to use when multiple accounts are connected for this provider. Get IDs from environment/credentials.json. If omitted, auto-selects the first available credential.', + 'Credential ID to use for this OAuth tool call. For Copilot/Superagent execution, pass this explicitly. Get valid IDs from environment/credentials.json.', }, }), } diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index ed9e0197ac..874bfccfeb 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -183,6 +183,7 @@ vi.mock('@/tools/registry', () => { name: 'Gmail Read', description: 'Read Gmail messages', version: '1.0.0', + oauth: { required: true, provider: 'google-email' }, params: {}, request: { url: '/api/tools/gmail/read', method: 'GET' }, }, @@ -191,6 +192,7 @@ vi.mock('@/tools/registry', () => { name: 'Gmail Send', description: 'Send Gmail messages', version: '1.0.0', + oauth: { required: true, provider: 'google-email' }, params: {}, request: { url: '/api/tools/gmail/send', method: 'POST' }, }, @@ -982,6 +984,37 @@ describe('Copilot File Parameter Normalization', () => { }) }) +describe('Copilot OAuth Credential Enforcement', () => { + let cleanupEnvVars: () => void + + beforeEach(() => { + process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' + cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) + }) + + afterEach(() => { + vi.resetAllMocks() + cleanupEnvVars() + }) + + it('fails fast when copilot executes an oauth tool without an explicit credential selector', async () => { + const fetchMock = vi.fn() + global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch + + const context = createToolExecutionContext({ + workspaceId: 'workspace-456', + copilotToolExecution: true, + } as any) + + const result = await executeTool('gmail_read', { maxResults: 5 }, false, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('credentialId') + expect(result.error).toContain('environment/credentials.json') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + describe('Centralized Error Handling', () => { let cleanupEnvVars: () => void diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 6062417a07..e95b286b4f 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -165,6 +165,43 @@ async function normalizeCopilotFileParams( } } +function readExplicitCredentialSelector(params: Record): string | undefined { + for (const key of ['credentialId', 'oauthCredential', 'credential'] as const) { + const value = params[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + } + return undefined +} + +function normalizeCopilotCredentialParams(params: Record): void { + const credentialId = typeof params.credentialId === 'string' ? params.credentialId.trim() : '' + if (credentialId && !params.credential && !params.oauthCredential) { + params.credential = credentialId + } +} + +function enforceCopilotCredentialSelection( + toolId: string, + tool: ToolConfig, + params: Record, + scope: ToolExecutionScope +): void { + if (!scope.copilotToolExecution || !tool.oauth?.required) { + return + } + + if (readExplicitCredentialSelector(params)) { + return + } + + const toolLabel = tool.name || toolId + throw new Error( + `Copilot must pass credentialId for ${toolLabel}. Read environment/credentials.json and pass the exact credentialId for provider "${tool.oauth.provider}".` + ) +} + /** Result from hosted key injection */ interface HostedKeyInjectionResult { isUsingHostedKey: boolean @@ -789,6 +826,8 @@ export async function executeTool( } await normalizeCopilotFileParams(tool, contextParams, scope) + normalizeCopilotCredentialParams(contextParams) + enforceCopilotCredentialSelection(toolId, tool, contextParams, scope) // Inject hosted API key if tool supports it and user didn't provide one const hostedKeyInfo = await injectHostedKeyIfNeeded( diff --git a/apps/sim/tools/params.test.ts b/apps/sim/tools/params.test.ts index 8aea57cd2e..619323ce17 100644 --- a/apps/sim/tools/params.test.ts +++ b/apps/sim/tools/params.test.ts @@ -142,6 +142,41 @@ describe('Tool Parameters Utils', () => { expect(schema.properties).toHaveProperty('message') }) + it.concurrent('adds credentialId only for copilot-facing oauth schemas', () => { + const oauthTool = { + ...mockToolConfig, + id: 'oauth_schema_tool', + oauth: { + required: true, + provider: 'google-email', + }, + params: { + message: { + type: 'string', + required: true, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Message to send', + }, + accessToken: { + type: 'string', + required: true, + visibility: 'hidden' as ParameterVisibility, + description: 'OAuth access token', + }, + }, + } + + const defaultSchema = createUserToolSchema(oauthTool) + const copilotSchema = createUserToolSchema(oauthTool, { surface: 'copilot' }) + + expect(defaultSchema.properties).not.toHaveProperty('credentialId') + expect(copilotSchema.properties).toHaveProperty('credentialId') + expect(copilotSchema.properties.credentialId).toMatchObject({ + type: 'string', + }) + expect(copilotSchema.required).toContain('credentialId') + }) + it.concurrent('keeps shared file params unchanged by default', () => { const toolWithFileParam = { ...mockToolConfig, diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index f23cefb4e3..838002333b 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -471,6 +471,7 @@ export function createUserToolSchema( toolConfig: ToolConfig, options: UserToolSchemaOptions = {} ): ToolSchema { + const surface = options.surface ?? 'default' const schema: ToolSchema = { type: 'object', properties: {}, @@ -492,12 +493,13 @@ export function createUserToolSchema( } } - if (toolConfig.oauth?.required) { + if (toolConfig.oauth?.required && surface === 'copilot') { schema.properties.credentialId = { type: 'string', description: - 'Optional credential ID to use when multiple accounts are connected for this provider. Get IDs from environment/credentials.json. If omitted, auto-selects the first available credential.', + 'Credential ID to use for this OAuth tool call. Required for Copilot/Superagent execution. Get valid IDs from environment/credentials.json.', } + schema.required.push('credentialId') } return schema diff --git a/bun.lock b/bun.lock index d5a11f32ad..e4fa813406 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio",