Skip to content

Commit 2dca8ba

Browse files
fix(cli): harden login and credentialless chat
1 parent 2db10c1 commit 2dca8ba

23 files changed

Lines changed: 542 additions & 482 deletions

File tree

apps/sim/app/api/knowledge/search/utils.test.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -666,23 +666,17 @@ describe('Knowledge Search Utils', () => {
666666
it('should throw error when no API configuration provided', async () => {
667667
const { env } = await import('@/lib/core/config/env')
668668
Object.keys(env).forEach((key) => delete (env as any)[key])
669-
// The env object lazily reads process.env, so a developer's local .env
670-
// keys survive the deletion above — stub the direct key empty and fail
671-
// the hosted rotation fallback for hermeticity on any machine.
672-
vi.stubEnv('OPENAI_API_KEY', '')
673-
const apiKeysModule = await import('@/lib/core/config/api-keys')
674-
const rotationSpy = vi.spyOn(apiKeysModule, 'getRotatingApiKey').mockImplementation(() => {
675-
throw new Error('No rotation keys configured')
669+
Object.assign(env, {
670+
OPENAI_API_KEY: undefined,
671+
OPENAI_API_KEY_1: undefined,
672+
OPENAI_API_KEY_2: undefined,
673+
OPENAI_API_KEY_3: undefined,
674+
OPENROUTER_API_KEY: undefined,
676675
})
677676

678-
try {
679-
await expect(generateSearchEmbedding('test query')).rejects.toThrow(
680-
'OPENAI_API_KEY is not configured'
681-
)
682-
} finally {
683-
rotationSpy.mockRestore()
684-
vi.unstubAllEnvs()
685-
}
677+
await expect(generateSearchEmbedding('test query')).rejects.toThrow(
678+
'OPENAI_API_KEY is not configured'
679+
)
686680
})
687681

688682
it('should handle Azure OpenAI API errors properly', async () => {
@@ -713,6 +707,7 @@ describe('Knowledge Search Utils', () => {
713707
Object.keys(env).forEach((key) => delete (env as any)[key])
714708
Object.assign(env, {
715709
OPENAI_API_KEY: 'test-openai-key',
710+
OPENROUTER_API_KEY: undefined,
716711
})
717712

718713
mockNextFetchResponse({

apps/sim/app/api/knowledge/utils.test.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -363,23 +363,17 @@ describe('Knowledge Utils', () => {
363363
it('should throw error when no API configuration provided', async () => {
364364
const { env } = await import('@/lib/core/config/env')
365365
Object.keys(env).forEach((key) => delete (env as any)[key])
366-
// The env object lazily reads process.env, so a developer's local .env
367-
// keys survive the deletion above — stub the direct key empty and fail
368-
// the hosted rotation fallback for hermeticity on any machine.
369-
vi.stubEnv('OPENAI_API_KEY', '')
370-
const apiKeysModule = await import('@/lib/core/config/api-keys')
371-
const rotationSpy = vi.spyOn(apiKeysModule, 'getRotatingApiKey').mockImplementation(() => {
372-
throw new Error('No rotation keys configured')
366+
Object.assign(env, {
367+
OPENAI_API_KEY: undefined,
368+
OPENAI_API_KEY_1: undefined,
369+
OPENAI_API_KEY_2: undefined,
370+
OPENAI_API_KEY_3: undefined,
371+
OPENROUTER_API_KEY: undefined,
373372
})
374373

375-
try {
376-
await expect(generateEmbeddings(['test text'])).rejects.toThrow(
377-
'OPENAI_API_KEY is not configured'
378-
)
379-
} finally {
380-
rotationSpy.mockRestore()
381-
vi.unstubAllEnvs()
382-
}
374+
await expect(generateEmbeddings(['test text'])).rejects.toThrow(
375+
'OPENAI_API_KEY is not configured'
376+
)
383377
})
384378
})
385379
})

apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts

Lines changed: 0 additions & 98 deletions
This file was deleted.

apps/sim/lib/api/server/routes/v2-resource-concealment.ts

Lines changed: 0 additions & 35 deletions
This file was deleted.

apps/sim/lib/copilot/chat/lifecycle.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import {
66
getActiveWorkflowRecord,
77
} from '@sim/platform-authz/workflow'
88
import { and, asc, eq, isNull, sql } from 'drizzle-orm'
9-
import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message'
9+
import {
10+
collectChatMcpServerIds,
11+
type PersistedMessage,
12+
stripToolResultOutput,
13+
} from '@/lib/copilot/chat/persisted-message'
1014
import {
1115
assertActiveWorkspaceAccess,
1216
checkWorkspaceAccess,
@@ -35,6 +39,11 @@ const copilotChatAuthColumns = {
3539
type: copilotChats.type,
3640
} as const
3741

42+
const copilotChatContinuationColumns = {
43+
...copilotChatAuthColumns,
44+
title: copilotChats.title,
45+
} as const
46+
3847
/**
3948
* Column set for chat-detail callers that need chat metadata. The conversation
4049
* transcript is no longer selected from `copilot_chats.messages` (JSONB) —
@@ -103,6 +112,12 @@ type CopilotChatAuthRow = Pick<
103112
'id' | 'userId' | 'workflowId' | 'workspaceId' | 'type'
104113
>
105114

115+
export type CopilotChatContinuationMetadata = CopilotChatAuthRow & {
116+
title: string | null
117+
hasMessages: boolean
118+
mcpServerIds: string[]
119+
}
120+
106121
export type CopilotChatDetailRow = Pick<
107122
typeof copilotChats.$inferSelect,
108123
| 'id'
@@ -181,6 +196,57 @@ export async function getAccessibleCopilotChatAuth(
181196
return authorizeCopilotChatRow(chat, chatId, userId)
182197
}
183198

199+
/**
200+
* Loads only the authorized metadata needed to continue a persisted chat. The
201+
* one-row existence probe preserves first-turn title behavior, while the MCP
202+
* query projects only user-message context arrays. Assistant/tool content is
203+
* never loaded or normalized.
204+
*/
205+
export async function getAccessibleCopilotChatContinuationMetadata(
206+
chatId: string,
207+
userId: string
208+
): Promise<CopilotChatContinuationMetadata | null> {
209+
const [chat] = await db
210+
.select(copilotChatContinuationColumns)
211+
.from(copilotChats)
212+
.where(ownedLiveChatWhere(chatId, userId))
213+
.limit(1)
214+
215+
const authorized = await authorizeCopilotChatRow(chat, chatId, userId)
216+
if (!authorized) return null
217+
218+
const [message] = await db
219+
.select({ id: copilotMessages.id })
220+
.from(copilotMessages)
221+
.where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt)))
222+
.limit(1)
223+
224+
if (!message) return { ...authorized, hasMessages: false, mcpServerIds: [] }
225+
226+
const contextRows = await db
227+
.select({ contexts: sql<unknown>`${copilotMessages.content} -> 'contexts'` })
228+
.from(copilotMessages)
229+
.where(
230+
and(
231+
eq(copilotMessages.chatId, chatId),
232+
eq(copilotMessages.role, 'user'),
233+
isNull(copilotMessages.deletedAt),
234+
sql`${copilotMessages.content} ? 'contexts'`
235+
)
236+
)
237+
.orderBy(
238+
sql`${copilotMessages.seq} asc nulls last`,
239+
asc(copilotMessages.createdAt),
240+
asc(copilotMessages.id)
241+
)
242+
243+
return {
244+
...authorized,
245+
hasMessages: true,
246+
mcpServerIds: collectChatMcpServerIds(contextRows),
247+
}
248+
}
249+
184250
/**
185251
* Load a copilot chat row for the legacy chat detail endpoint, including the
186252
* transcript plus `model` and `config`. Drops `previewYaml`

apps/sim/lib/copilot/chat/persisted-message.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,36 @@ export interface PersistedMessage {
127127
contexts?: PersistedMessageContext[]
128128
}
129129

130+
/**
131+
* Collect the append-only MCP enablement carried by explicitly tagged user
132+
* message contexts. Only ids move between turns: inherited contexts are not
133+
* re-expanded into the prompt or persisted again as chips on later messages.
134+
*/
135+
export function collectChatMcpServerIds(
136+
conversationHistory: readonly unknown[],
137+
currentContexts?: unknown
138+
): string[] {
139+
const serverIds = new Set<string>()
140+
141+
const collect = (contexts: unknown) => {
142+
if (!Array.isArray(contexts)) return
143+
for (const context of contexts) {
144+
if (!context || typeof context !== 'object') continue
145+
const { kind, serverId } = context as { kind?: unknown; serverId?: unknown }
146+
if (kind === 'mcp' && typeof serverId === 'string' && serverId) {
147+
serverIds.add(serverId)
148+
}
149+
}
150+
}
151+
152+
for (const message of conversationHistory) {
153+
collect((message as { contexts?: unknown } | null)?.contexts)
154+
}
155+
collect(currentContexts)
156+
157+
return Array.from(serverIds)
158+
}
159+
130160
/**
131161
* Drop persisted tool outputs, keeping `success` and `error`. The one narrow
132162
* UI-state exception is a browser takeover's user-authored instruction, which

apps/sim/lib/copilot/chat/post.ts

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload'
1616
import {
1717
buildPersistedAssistantMessage,
1818
buildPersistedUserMessage,
19+
collectChatMcpServerIds,
1920
withStoppedContentBlock,
2021
} from '@/lib/copilot/chat/persisted-message'
2122
import {
@@ -416,44 +417,6 @@ function normalizeContexts(contexts: UnifiedChatRequest['contexts']) {
416417
})
417418
}
418419

419-
/**
420-
* An MCP server tagged with `/name` stays enabled for the rest of the chat, not
421-
* just the turn it was tagged on. Persisted user messages already carry their
422-
* `mcp` contexts, so the transcript is the source of truth — enablement survives
423-
* reloads and reopened chats with no extra state to keep in sync. There is
424-
* deliberately no off switch: history is append-only.
425-
*
426-
* Only the ids travel forward, not the contexts themselves. The tools ride the
427-
* tool array on every turn, so the model always sees their names and schemas;
428-
* re-expanding the prompt listing each turn would just duplicate that. Keeping
429-
* inherited servers out of the persisted contexts also keeps the `/name` chips
430-
* on a sent message showing only what the user actually typed that turn.
431-
*/
432-
function collectChatMcpServerIds(
433-
conversationHistory: unknown[],
434-
currentContexts: UnifiedChatRequest['contexts']
435-
): string[] {
436-
const serverIds = new Set<string>()
437-
438-
const collect = (contexts: unknown) => {
439-
if (!Array.isArray(contexts)) return
440-
for (const ctx of contexts) {
441-
if (!ctx || typeof ctx !== 'object') continue
442-
const { kind, serverId } = ctx as { kind?: unknown; serverId?: unknown }
443-
if (kind === 'mcp' && typeof serverId === 'string' && serverId) {
444-
serverIds.add(serverId)
445-
}
446-
}
447-
}
448-
449-
for (const message of conversationHistory) {
450-
collect((message as { contexts?: unknown } | null)?.contexts)
451-
}
452-
collect(currentContexts)
453-
454-
return Array.from(serverIds)
455-
}
456-
457420
async function resolveAgentContexts(params: {
458421
contexts?: UnifiedChatRequest['contexts']
459422
resourceAttachments?: UnifiedChatRequest['resourceAttachments']

0 commit comments

Comments
 (0)