Skip to content

Commit f03b433

Browse files
Sg312claudeemir-karabegj15zicecrasher321
authored
feat(mothership): mixture of models, search agent, persistent subagents, fork chat, inline questions (mothership v0.8) (#5410)
* feat(scout): add scout agent * fix(contracts): update contracts to include scout agent * feat(copilot): search agent (research+scout merge) + read-only table/KB tool handlers Mirrors mothership dev f90f9b05: - regenerated tool-catalog/tool-schemas mirrors (search trigger replaces research + scout; QueryUserTable / SearchKnowledgeBase entries) - queryUserTableServerTool / searchKnowledgeBaseServerTool: read-only wrappers delegating to the full user_table / knowledge_base handlers with hard operation allowlists (and outputPath export rejection on query_user_table) - display maps: 'search' agent label/title/icon added; research + scout entries retained so historical transcripts keep rendering - Search.id replaces Research.id in LONG_RUNNING_TOOL_IDS (it inherits research's long crawls) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(copilot): run_code compute-only handler; docs lint fix Mirrors mothership dev db60da94: run_code is the compute-only variant of function_execute for the search agent — same sandbox and inputs, no outputs.files / outputTable, so it cannot create or overwrite workspace resources. Wrapper handler hard-rejects the write vectors and delegates to executeFunctionExecute; run_code is deliberately absent from OUTPUT_PATH_TOOLS and the table output post-processor, so the name gating blocks writes even for leaked args. Added to LONG_RUNNING_TOOL_IDS, display title/icon maps, and the regenerated catalog/schema mirrors. Also removes two ineffective biome suppression comments in the docs workflow-preview (the rule doesn't fire in the docs app config). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(copilot): failed tool calls must surface their error in terminal data A failed handler result that carried a defined-but-empty output (the app-tool executor's 'Tool not found' ships output: {}) won the priority race in getToolCallTerminalData, so the resume payload's data — the only thing the model reads — was a bare {} with the error text dropped. The search agent retried run_code 20+ times blind against a stale server because every failure rendered as empty instead of 'Tool not found'. Failed calls now always carry error in their terminal data: merged into object outputs, wrapped alongside non-object outputs, preserved when the output already has an error field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(chat): render inline question tags from the agent in chat * fix(chat): let inert multi-step questions browse all prompts * improvement(chat): guard question answer formatting against sparse arrays * chore(copilot): drop user_memory from generated contracts and tool display Companion to mothership 8ae32e97 (user_memory tool removed — the feature no longer exists). Regenerates the mothership contract mirrors via generate-mship-contracts.ts, which also picks up the pending telemetry contract additions (gen_ai.agent.name labels, llm.client.context_tokens, llm.client.compactions, llm.request.compaction_trigger, llm.compaction.pause, gen_ai.usage.context_tokens), and removes the user_memory display title. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * improvement(chat): answered question card becomes the user turn; two select types only UI ordering: answering a question card no longer echoes a duplicate user bubble. The combined answer still goes on the wire as a user message, but the chat pairs it back to its card (strict 'Prompt — Answer' match, now uniform for single questions too) and renders the card as the answered recap — the card IS the user turn, and the next assistant message streams below it. The pairing is derived from the transcript, so live and reloaded renders are identical; a dismissed card followed by an unrelated typed message does not match and renders normally. Messages ending with a question card also drop the copy/thumbs actions row — the card is an input surface, not a reactable assistant turn. Question types are now single_select and multi_select only: text is removed (the free-text 'Something else' row covers it) and confirm collapses into single_select with Yes/No options. multi_select rows toggle with a check and the free-text row's arrow submits the step; answers are comma-joined labels plus any typed entry. Agent-supplied catch-all options ('Other', 'Something else', 'None of the above') are stripped at parse — the card always provides its own free-text row; a question left with no real options is invalid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * improvement(chat): question cards are single_select only Removes multi_select (and its toggle/check UI). The card is one shape: pick one option or type into the always-present 'Something else' row. Catch-all stripping and the transcript pairing/recap behavior are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * improvement(chat): bring back multi_select question cards Re-adds multi_select with a reworked interaction: option rows carry real checkboxes (emcn Checkbox chrome) instead of numbers and arrows, an option-styled Submit row confirms the step, and the "Something else" row reads as a plain option until clicked — then it becomes the focused text box, auto-checks, and can be unchecked without losing the typed text (blur with nothing typed reverts it). single_select behavior, catch-all stripping, and the transcript pairing/recap format are unchanged; multi_select answers are the checked labels comma-joined. * chore(copilot): regenerate mothership contract mirror (chat blob span attrs) * chore(copilot): regenerate mothership contract mirror (chat blob metrics) * feat(secrets): make output of generate api key a secret * feat(cli): add mkdir, mv, cp to mship tool set * feat(fork-chat): add fork chat to mothership * fix(fork-chat): fix messageid handling in fork chat * feat(credentials): agent-initiated oauth credential reconnect (#5488) * feat(credentials): agent-initiated oauth credential reconnect * fix(credentials): address reconnect review findings * improvement(credentials): log when connect draft name lookups degrade * fix(conflicts): remove migration * fix(conflicts): fix conflicts * fix(fork-chat): add migrations back * fix(ci): fix lint * fix(ci): fix bad import * fix(vfs): fix 500 char limit in vfs for skills and custom tools * feat(copilot): gate user skills to explicit slash-attach (#5536) Stop the mothership from adopting a workspace user-skill on its own: - Remove the load_user_skill tool and its three payload callers (chat payload, mothership execute route, inbox executor); delete lib/mothership/skills.ts + its test. Skills no longer autoload as the agent's own instructions. - Rename the workspace "## Skills" inventory to "## Agent Block Skills — NOT FOR YOU" with a one-line guardrail so a skill's description (e.g. "respond like a pirate") is not treated as an instruction. Skills reach the model as behavior only via explicit /-attach. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(lots-of-things): lots of things * feat(subagents): add persistent subagents * fix(copilot): let edit_workflow set knowledge-base tag filters, and stop it clearing them (#5546) * fix(copilot): persist KB tag subblocks as JSON strings from edit_workflow The edit_workflow tool normalizes array-with-id subblocks (via normalizeArrayWithIds) but only re-stringifies the keys listed in JSON_STRING_SUBBLOCK_KEYS. `tagFilters` (knowledge-tag-filters) and `documentTags` (document-tag-entry) were missing, so agent-authored tag filters were stored as raw JSON arrays while those UI components read their value with JSON.parse (expecting a string). The result: an agent edit to a Knowledge block's tag filter persisted correctly but rendered as an empty filter in the editor (JSON.parse on an array throws -> []). - Add `tagFilters` and `documentTags` to JSON_STRING_SUBBLOCK_KEYS so edit_workflow stores them in the same shape the UI writes. - Make both components' parsers tolerate an already-parsed array on read, self-healing values already persisted in the broken (array) shape. Search execution was unaffected (parseTagFilters accepts arrays), so the value was never lost — only the editor render and round-trip were broken. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(copilot): expose KB tag definitions in VFS meta.json Surface each knowledge base's defined tags (displayName -> tagSlot) inline in its meta.json via serializeKBMeta, loaded in one batched query (loadKbTagDefinitions), so the agent can bind a knowledge-tag filter to a real tag slot instead of guessing a tag name it cannot otherwise see. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): stringify KB tag subblocks on the nested-node edit path The nested-node merge path normalized array-with-id subblocks but never re-serialized the JSON_STRING_SUBBLOCK_KEYS, so editing a block nested in a loop/parallel container still persisted tagFilters/documentTags (and conditions/routes) as raw arrays -- the exact shape the subblock components cannot JSON.parse. Route all four write paths through a single normalizeSubblockValue helper so the normalize and re-stringify steps cannot drift apart again, and extract the duplicated string-or-array read logic into parseJsonArrayValue. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(copilot): tighten subblock serialization helpers Derive KbTagDefinitionSummary from the canonical TagDefinition instead of restating its fields, make parseJsonArrayValue generic so callers drop their `as T[]` casts, and unexport the three builders helpers that no longer have consumers outside the module now that normalizeSubblockValue fronts them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): stop stripping tagFilters/documentTags from the agent's workflow view sanitizeForCopilot dropped `tagFilters` and `documentTags` from the workflow state the agent reads (workflows/{name}/state.json), while edit_workflow is allowed to write both. The field was therefore write-only: on a follow-up edit the agent read back an absent field, concluded no filter was set, and cleared the user's tag filter. The redaction was introduced for workflow *export* (#1628) and is already enforced there by sanitizeWorkflowForSharing's key list. The duplicate in the copilot-only sanitizeSubBlocks was redundant for export and destructive for the agent. Removes it and pins the contract with a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): reject malformed KB tag values instead of clearing the filter `knowledge-tag-filters` and `document-tag-entry` had no arm in the `edit_workflow` input validator, so they fell through to the pass-through default. Any non-array value the agent supplied -- a double-encoded JSON string, an object, an unparseable string -- reached `normalizeSubblockValue`, where `normalizeArrayWithIds` coerces unparseable input to `[]`. The write path then persisted `"[]"` over the tag filter the user had configured. `condition-input` and `router-input` already guard against exactly this and return an actionable error to the model. Extend that arm to cover the two KB subblock types. It keys on subblock type, so the unrelated `tagFilters` short-input on the Algolia block is unaffected. `null`/`undefined` and empty arrays still clear the field, so intentional clears keep working. Also wrap `loadKbTagDefinitions` in try/catch. Tag definitions are an optional meta.json enrichment, but the query ran inside the top-level `Promise.all`, so a transient failure would reject the entire workspace VFS materialize and leave the agent unable to read any file. Now it degrades to a meta.json without tag definitions, matching the sibling materializers. Adds regression tests for both, plus the first tests for `parseJsonArrayValue`, the helper that keeps pre-fix raw-array rows readable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(copilot): collapse duplicate JSON-array parsing in edit-workflow builders `normalizeArrayWithIds` and `normalizeConditionRouterIds` each hand-rolled the same "accept a raw array or the JSON string these subblocks persist" parse. Extract `parseJsonArray`, which returns null when the value is neither, so each caller keeps its own distinct fallback: `[]` for the former, the untouched original value for the latter. Behavior-preserving. An empty array is truthy, so `[]` and `"[]"` still parse through rather than hitting either fallback. `validation.ts` has a third copy, but `builders.ts` already imports from it, so sharing the helper across the two would introduce an import cycle. Left as is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(copilot): specify tag name and legal operators in KB meta.json `tagDefinitions` exposed `displayName`, but a `tagFilters` entry must carry the key `tagName`. An entry written with `displayName` passes validation and persists, then filters nothing -- a silent failure. Rename the field at the serializer boundary; the DB column is untouched. Also emit the operators legal for each tag's `fieldType`, reusing `getOperatorsForFieldType`. `between` is valid for number and date but not for text or boolean, and the agent has no way to infer that. An unrecognized fieldType yields an empty list rather than throwing. Still unspecified, and deliberately out of scope: a filter entry's value key is `tagValue` (but `value` on documentTags), and `between` needs `valueTo`. Those describe the subblock entry shape, not the knowledge base, so meta.json is the wrong place for them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot): pass a nullish subblock clear through instead of serializing "[]" `validateValueForSubBlockType` accepts null as an explicit clear, but `normalizeSubblockValue` then ran it through `normalizeArrayWithIds`, which coerces any non-array to `[]`, and persisted the string "[]". No data is lost either way -- "[]" and an absent field both mean "no filters". But it left the field present when the caller asked for it to be unset, so `sanitizeForCopilot` showed the agent an empty filter rather than an absent one, contradicting the absent-means-unset invariant the sanitizer documents. It also made Algolia's `if (params.tagFilters)` see a set value, since "[]" is truthy. An explicitly empty array still serializes to "[]" -- clearing with a value is distinct from clearing by omission. Reported by Cursor Bugbot on #5546. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(changes): huge changes * fix(subagents): lanes * fix(mship): transcript stuff * fix(subagents): thinking lanes * fix(superagent): fix superagent tools and checkpoints * fix(scope): scope subagent tools * fix(lint): fix lint * chore(db): regenerate workspace_files.message_id migration as 0260 on staging base * fix(superagent): fix superagent integration tools * improvement(questions): make something else a placeholder * chore(copilot): regenerate mothership contract mirror after staging rebase * feat(mship): add external mcps to mship * fix(ci): fix dev build * fix(stream): show thinking text * fix(ci): force redeploy * fix(mothership): keep chat forks outside workspace storage billing Preserve the product invariant that Mothership chat files are not charged as workspace file storage after the billing storage merge. * fix(uploads): restore listWorkspaceFiles throwOnError option dropped in rebase * fix(subagent-streaming): remove italics * fix(mothership): treat subagent lanes closed by subagent_end as settled so the between-steps thinking indicator isn't suppressed * fix(ui): thinking loader and rool names * fix(ui): add file * fix(thinking): show thinking during subagents * fix(chat): drop dead thinking-channel ternary after lanes skip thinking blocks * fix(thinking): remove thinking text * improvement(function execute): add timeout to function execute and stop showing text in subagents * fix(subagents): hide thinking text * fix(ff): move ff to go * improvement(superagent): nuke superagent * feat(main-agent): superagent into main agent * chore(db): regenerate workspace_files.message_id migration as 0262 on staging base * fix(credentials): restore reconnect params on shared createConnectDraft * fix(migrations): rebase with staging --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu> Co-authored-by: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
1 parent 68c4f28 commit f03b433

175 files changed

Lines changed: 28449 additions & 1555 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockGetSession,
9+
mockOAuth2LinkAccount,
10+
mockCheckWorkspaceAccess,
11+
mockGetCredentialActorContext,
12+
} = vi.hoisted(() => ({
13+
mockGetSession: vi.fn(),
14+
mockOAuth2LinkAccount: vi.fn(),
15+
mockCheckWorkspaceAccess: vi.fn(),
16+
mockGetCredentialActorContext: vi.fn(),
17+
}))
18+
19+
vi.mock('@sim/db', () => dbChainMock)
20+
21+
vi.mock('@/lib/auth/auth', () => ({
22+
auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } },
23+
getSession: mockGetSession,
24+
}))
25+
26+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
27+
checkWorkspaceAccess: mockCheckWorkspaceAccess,
28+
}))
29+
30+
vi.mock('@/lib/credentials/access', () => ({
31+
getCredentialActorContext: mockGetCredentialActorContext,
32+
}))
33+
34+
vi.mock('@/lib/oauth/utils', () => ({
35+
getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]),
36+
}))
37+
38+
import { GET } from '@/app/api/auth/oauth2/authorize/route'
39+
40+
const BASE_URL = 'https://sim.test'
41+
const WORKSPACE_ID = 'ws-1'
42+
const USER_ID = 'user-1'
43+
const CREDENTIAL_ID = 'cred-1'
44+
const LINK_URL = 'https://provider.example/authorize?state=abc'
45+
46+
function authorizeRequest(query: Record<string, string>) {
47+
const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`)
48+
for (const [key, value] of Object.entries(query)) {
49+
url.searchParams.set(key, value)
50+
}
51+
return createMockRequest('GET', undefined, {}, url.toString())
52+
}
53+
54+
function oauthCredentialActor(overrides: Record<string, unknown> = {}) {
55+
return {
56+
credential: {
57+
id: CREDENTIAL_ID,
58+
workspaceId: WORKSPACE_ID,
59+
type: 'oauth',
60+
providerId: 'google-email',
61+
displayName: 'Work Gmail',
62+
...((overrides.credential as Record<string, unknown>) ?? {}),
63+
},
64+
member: null,
65+
hasWorkspaceAccess: true,
66+
canWriteWorkspace: true,
67+
isAdmin: true,
68+
...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')),
69+
}
70+
}
71+
72+
describe('OAuth2 authorize route', () => {
73+
beforeEach(() => {
74+
vi.clearAllMocks()
75+
resetDbChainMock()
76+
process.env.NEXT_PUBLIC_APP_URL = BASE_URL
77+
mockGetSession.mockResolvedValue({ user: { id: USER_ID } })
78+
mockCheckWorkspaceAccess.mockResolvedValue({
79+
hasAccess: true,
80+
canWrite: true,
81+
canAdmin: false,
82+
workspace: { id: WORKSPACE_ID },
83+
})
84+
mockOAuth2LinkAccount.mockResolvedValue({
85+
ok: true,
86+
status: 200,
87+
json: async () => ({ url: LINK_URL }),
88+
headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] },
89+
})
90+
})
91+
92+
describe('plain connect (no credentialId)', () => {
93+
it('creates a draft with credentialId null and redirects to the provider', async () => {
94+
const response = await GET(
95+
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
96+
)
97+
98+
expect(response.headers.get('location')).toBe(LINK_URL)
99+
expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
100+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
101+
expect.objectContaining({
102+
userId: USER_ID,
103+
workspaceId: WORKSPACE_ID,
104+
providerId: 'google-email',
105+
credentialId: null,
106+
})
107+
)
108+
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
109+
expect.objectContaining({
110+
set: expect.objectContaining({ credentialId: null }),
111+
})
112+
)
113+
})
114+
115+
it('numbers the draft display name when the default collides with an existing credential', async () => {
116+
dbChainMockFns.where
117+
.mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }]))
118+
.mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }]))
119+
120+
await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
121+
122+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
123+
expect.objectContaining({ displayName: "Justin's Gmail 2" })
124+
)
125+
})
126+
127+
it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => {
128+
await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
129+
130+
const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0]
131+
expect(set).toHaveProperty('credentialId', null)
132+
})
133+
134+
it('redirects to login when unauthenticated', async () => {
135+
mockGetSession.mockResolvedValue(null)
136+
137+
const response = await GET(
138+
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
139+
)
140+
141+
expect(response.headers.get('location')).toContain('/login')
142+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
143+
})
144+
145+
it('rejects without workspace write access', async () => {
146+
mockCheckWorkspaceAccess.mockResolvedValue({
147+
hasAccess: true,
148+
canWrite: false,
149+
canAdmin: false,
150+
workspace: { id: WORKSPACE_ID },
151+
})
152+
153+
const response = await GET(
154+
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
155+
)
156+
157+
expect(response.headers.get('location')).toBe(
158+
`${BASE_URL}/workspace?error=workspace_access_denied`
159+
)
160+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
161+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
162+
})
163+
})
164+
165+
describe('reconnect (credentialId present)', () => {
166+
it('creates a reconnect draft carrying credentialId in values and upsert set', async () => {
167+
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())
168+
169+
const response = await GET(
170+
authorizeRequest({
171+
providerId: 'google-email',
172+
workspaceId: WORKSPACE_ID,
173+
credentialId: CREDENTIAL_ID,
174+
})
175+
)
176+
177+
expect(response.headers.get('location')).toBe(LINK_URL)
178+
expect(mockGetCredentialActorContext).toHaveBeenCalledWith(
179+
CREDENTIAL_ID,
180+
USER_ID,
181+
expect.objectContaining({ workspaceAccess: expect.anything() })
182+
)
183+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
184+
expect.objectContaining({ credentialId: CREDENTIAL_ID })
185+
)
186+
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
187+
expect.objectContaining({
188+
set: expect.objectContaining({ credentialId: CREDENTIAL_ID }),
189+
})
190+
)
191+
})
192+
193+
it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => {
194+
mockGetCredentialActorContext.mockResolvedValue(
195+
oauthCredentialActor({ credential: { displayName: 'Renamed By User' } })
196+
)
197+
198+
await GET(
199+
authorizeRequest({
200+
providerId: 'google-email',
201+
workspaceId: WORKSPACE_ID,
202+
credentialId: CREDENTIAL_ID,
203+
})
204+
)
205+
206+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
207+
expect.objectContaining({ displayName: 'Renamed By User' })
208+
)
209+
})
210+
211+
it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => {
212+
for (const providerId of ['trello', 'shopify']) {
213+
const response = await GET(
214+
authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID })
215+
)
216+
217+
expect(response.headers.get('location')).toBe(
218+
`${BASE_URL}/workspace?error=credential_reconnect_unsupported`
219+
)
220+
}
221+
expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
222+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
223+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
224+
})
225+
226+
it('rejects when the caller is not a credential admin and writes no draft', async () => {
227+
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false }))
228+
229+
const response = await GET(
230+
authorizeRequest({
231+
providerId: 'google-email',
232+
workspaceId: WORKSPACE_ID,
233+
credentialId: CREDENTIAL_ID,
234+
})
235+
)
236+
237+
expect(response.headers.get('location')).toBe(
238+
`${BASE_URL}/workspace?error=credential_access_denied`
239+
)
240+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
241+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
242+
})
243+
244+
it('rejects when the credential belongs to a different workspace', async () => {
245+
mockGetCredentialActorContext.mockResolvedValue(
246+
oauthCredentialActor({ credential: { workspaceId: 'ws-other' } })
247+
)
248+
249+
const response = await GET(
250+
authorizeRequest({
251+
providerId: 'google-email',
252+
workspaceId: WORKSPACE_ID,
253+
credentialId: CREDENTIAL_ID,
254+
})
255+
)
256+
257+
expect(response.headers.get('location')).toBe(
258+
`${BASE_URL}/workspace?error=credential_access_denied`
259+
)
260+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
261+
})
262+
263+
it('rejects when the credential does not exist', async () => {
264+
mockGetCredentialActorContext.mockResolvedValue({
265+
credential: null,
266+
member: null,
267+
hasWorkspaceAccess: false,
268+
canWriteWorkspace: false,
269+
isAdmin: false,
270+
})
271+
272+
const response = await GET(
273+
authorizeRequest({
274+
providerId: 'google-email',
275+
workspaceId: WORKSPACE_ID,
276+
credentialId: 'cred-missing',
277+
})
278+
)
279+
280+
expect(response.headers.get('location')).toBe(
281+
`${BASE_URL}/workspace?error=credential_access_denied`
282+
)
283+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
284+
})
285+
286+
it('rejects a non-oauth credential', async () => {
287+
mockGetCredentialActorContext.mockResolvedValue(
288+
oauthCredentialActor({ credential: { type: 'env_workspace' } })
289+
)
290+
291+
const response = await GET(
292+
authorizeRequest({
293+
providerId: 'google-email',
294+
workspaceId: WORKSPACE_ID,
295+
credentialId: CREDENTIAL_ID,
296+
})
297+
)
298+
299+
expect(response.headers.get('location')).toBe(
300+
`${BASE_URL}/workspace?error=credential_access_denied`
301+
)
302+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
303+
})
304+
305+
it('rejects when the query providerId does not match the credential provider', async () => {
306+
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())
307+
308+
const response = await GET(
309+
authorizeRequest({
310+
providerId: 'slack',
311+
workspaceId: WORKSPACE_ID,
312+
credentialId: CREDENTIAL_ID,
313+
})
314+
)
315+
316+
expect(response.headers.get('location')).toBe(
317+
`${BASE_URL}/workspace?error=credential_provider_mismatch`
318+
)
319+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
320+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
321+
})
322+
})
323+
})

0 commit comments

Comments
 (0)