Skip to content

Commit c5a9b6a

Browse files
authored
feat(secrets): let mship add secret descriptions (#6814)
* feat(copilot): support workspace secret descriptions * feat(copilot): save secret card descriptions * test(copilot): cover secret card descriptions * fix(copilot): update secret descriptions without values
1 parent c17043a commit c5a9b6a

10 files changed

Lines changed: 493 additions & 37 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ const {
1414
mockSendBrowserPanelAction,
1515
mockUpsertWorkspaceEnvironment,
1616
mockUseUserPermissionsContext,
17+
mockUpdateWorkspaceCredential,
1718
mockUseWorkspaceCredential,
1819
mockUseWorkspaceCredentials,
1920
} = vi.hoisted(() => ({
21+
mockUpdateWorkspaceCredential: vi.fn(async () => undefined),
2022
mockRefetchPersonalEnvironment: vi.fn(async () => ({ data: {} })),
2123
mockRefetchWorkspaceCredentials: vi.fn(async () => ({ data: [] })),
2224
mockIsBrowserAgentAvailable: vi.fn(() => false),
@@ -37,6 +39,7 @@ vi.mock('next/navigation', () => ({
3739
}))
3840

3941
vi.mock('@/hooks/queries/credentials', () => ({
42+
useUpdateWorkspaceCredential: () => ({ mutateAsync: mockUpdateWorkspaceCredential }),
4043
useWorkspaceCredential: mockUseWorkspaceCredential,
4144
useWorkspaceCredentials: mockUseWorkspaceCredentials,
4245
}))
@@ -1144,6 +1147,86 @@ describe('CredentialDisplay link tag', () => {
11441147
act(() => root.unmount())
11451148
})
11461149

1150+
it('attaches an agent-authored description after the secret value is saved', async () => {
1151+
mockUseUserPermissionsContext.mockReturnValue({ canEdit: true })
1152+
mockRefetchWorkspaceCredentials.mockResolvedValueOnce({
1153+
data: [{ id: 'cred-1', envKey: 'WORKSPACE_KEY' }],
1154+
})
1155+
const container = document.createElement('div')
1156+
const root = createRoot(container)
1157+
const data: CredentialItemData[] = [
1158+
{
1159+
type: 'secret_input',
1160+
name: 'WORKSPACE_KEY',
1161+
scope: 'workspace',
1162+
description: ' Stripe live key for billing ',
1163+
},
1164+
]
1165+
1166+
act(() => {
1167+
root.render(<SpecialTags segment={{ type: 'credential', data }} onOptionSelect={vi.fn()} />)
1168+
})
1169+
1170+
const input = container.querySelector('input')
1171+
act(() => {
1172+
if (!input) return
1173+
const valueSetter = Object.getOwnPropertyDescriptor(
1174+
window.HTMLInputElement.prototype,
1175+
'value'
1176+
)?.set
1177+
valueSetter?.call(input, 'sk-live-123')
1178+
input.dispatchEvent(new Event('input', { bubbles: true }))
1179+
})
1180+
const submitButton = Array.from(container.querySelectorAll('button')).find(
1181+
(button) => button.textContent === 'Submit'
1182+
)
1183+
await act(async () => submitButton?.click())
1184+
1185+
expect(mockUpsertWorkspaceEnvironment).toHaveBeenCalledWith({
1186+
workspaceId: 'workspace-1',
1187+
variables: { WORKSPACE_KEY: 'sk-live-123' },
1188+
})
1189+
// The description rides the credential row the value save just created, so
1190+
// the trimmed note lands without the user ever seeing the field.
1191+
expect(mockUpdateWorkspaceCredential).toHaveBeenCalledWith({
1192+
credentialId: 'cred-1',
1193+
description: 'Stripe live key for billing',
1194+
})
1195+
act(() => root.unmount())
1196+
})
1197+
1198+
it('leaves a personal secret undescribed', async () => {
1199+
mockUseUserPermissionsContext.mockReturnValue({ canEdit: true })
1200+
const container = document.createElement('div')
1201+
const root = createRoot(container)
1202+
const data: CredentialItemData[] = [
1203+
{ type: 'secret_input', name: 'PERSONAL_KEY', scope: 'personal', description: 'my key' },
1204+
]
1205+
1206+
act(() => {
1207+
root.render(<SpecialTags segment={{ type: 'credential', data }} onOptionSelect={vi.fn()} />)
1208+
})
1209+
1210+
const input = container.querySelector('input')
1211+
act(() => {
1212+
if (!input) return
1213+
const valueSetter = Object.getOwnPropertyDescriptor(
1214+
window.HTMLInputElement.prototype,
1215+
'value'
1216+
)?.set
1217+
valueSetter?.call(input, 'personal-secret')
1218+
input.dispatchEvent(new Event('input', { bubbles: true }))
1219+
})
1220+
const submitButton = Array.from(container.querySelectorAll('button')).find(
1221+
(button) => button.textContent === 'Submit'
1222+
)
1223+
await act(async () => submitButton?.click())
1224+
1225+
expect(mockSavePersonalEnvironment).toHaveBeenCalled()
1226+
expect(mockUpdateWorkspaceCredential).not.toHaveBeenCalled()
1227+
act(() => root.unmount())
1228+
})
1229+
11471230
it('renders one status recap from a transcript submission', () => {
11481231
const container = document.createElement('div')
11491232
const root: Root = createRoot(container)

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { createElement, lazy, Suspense, useEffect, useMemo, useState } from 'react'
3+
import { createElement, lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
44
import {
55
ArrowRight,
66
Check,
@@ -61,7 +61,11 @@ import type {
6161
import { useServiceAccountConnectTarget } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect'
6262
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
6363
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
64-
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
64+
import {
65+
useUpdateWorkspaceCredential,
66+
useWorkspaceCredential,
67+
useWorkspaceCredentials,
68+
} from '@/hooks/queries/credentials'
6569
import {
6670
usePersonalEnvironment,
6771
useSavePersonalEnvironment,
@@ -137,6 +141,12 @@ export interface CredentialItemData {
137141
name?: string
138142
/** Where a secret_input value is persisted. Defaults to "workspace". */
139143
scope?: SecretInputScope
144+
/**
145+
* What the secret is for (secret_input, workspace scope only), written by the
146+
* agent that asked for it. Never shown or editable in the card — it exists so
147+
* the saved secret carries its purpose into workspace settings.
148+
*/
149+
description?: string
140150
/**
141151
* Existing credential to reconnect in place (service_account only). Present =
142152
* rotate the secret on this credential; absent = create a new one.
@@ -1751,6 +1761,63 @@ interface CredentialControlProps {
17511761
onConnected?: () => void
17521762
}
17531763

1764+
/**
1765+
* Attaches the agent-authored descriptions to workspace secrets once their values
1766+
* are saved, reusing the credential update endpoint the secrets settings page
1767+
* calls. It runs after the value write because that write is what mints the
1768+
* credential row a description hangs on, and it is best-effort: the value is the
1769+
* point of the card, so a failed note never fails the save. Personal rows are
1770+
* skipped — their credential rows are per-workspace mirrors of one user-global
1771+
* secret, so no single row can own a description.
1772+
*/
1773+
function useWorkspaceSecretDescriptions(items: CredentialItemData[]) {
1774+
const { workspaceId } = useParams<{ workspaceId: string }>()
1775+
const describedByName = useMemo(() => {
1776+
const entries = new Map<string, string>()
1777+
for (const item of items) {
1778+
if (item.type !== 'secret_input' || item.scope === 'personal') continue
1779+
const name = item.name?.trim()
1780+
const description = item.description?.trim()
1781+
if (name && description) entries.set(name, description)
1782+
}
1783+
return entries
1784+
}, [items])
1785+
1786+
const credentialsQuery = useWorkspaceCredentials({
1787+
workspaceId,
1788+
type: 'env_workspace',
1789+
enabled: describedByName.size > 0,
1790+
})
1791+
const updateCredential = useUpdateWorkspaceCredential()
1792+
const refetchCredentials = credentialsQuery.refetch
1793+
1794+
return useCallback(
1795+
async (savedNames: string[]) => {
1796+
const pending = savedNames.filter((name) => describedByName.has(name))
1797+
if (pending.length === 0) return
1798+
1799+
try {
1800+
const { data } = await refetchCredentials()
1801+
const idByEnvKey = new Map((data ?? []).map((row) => [row.envKey, row.id]))
1802+
await Promise.all(
1803+
pending.map(async (name) => {
1804+
const credentialId = idByEnvKey.get(name)
1805+
if (!credentialId) return
1806+
await updateCredential.mutateAsync({
1807+
credentialId,
1808+
description: describedByName.get(name),
1809+
})
1810+
})
1811+
)
1812+
} catch {
1813+
// Swallowed deliberately: the secret is stored, and the card must not
1814+
// report failure over a missing note.
1815+
}
1816+
},
1817+
[describedByName, refetchCredentials, updateCredential.mutateAsync]
1818+
)
1819+
}
1820+
17541821
function SecretInputDisplay({ data, divided = false, onSaved }: CredentialControlProps) {
17551822
const { workspaceId } = useParams<{ workspaceId: string }>()
17561823
const secretName = (data.name ?? '').trim()
@@ -1765,6 +1832,7 @@ function SecretInputDisplay({ data, divided = false, onSaved }: CredentialContro
17651832
const personalQuery = usePersonalEnvironment()
17661833
const personalEnv = personalQuery.data
17671834
const { canEdit } = useUserPermissionsContext()
1835+
const attachDescriptions = useWorkspaceSecretDescriptions(useMemo(() => [data], [data]))
17681836

17691837
// Setting a workspace var needs write/admin (same gate as the secrets manager);
17701838
// personal vars are the user's own, so any member may set them.
@@ -1790,6 +1858,7 @@ function SecretInputDisplay({ data, divided = false, onSaved }: CredentialContro
17901858
await savePersonal.mutateAsync({ variables: merged })
17911859
} else {
17921860
await upsertWorkspace.mutateAsync({ workspaceId, variables: { [secretName]: value } })
1861+
await attachDescriptions([secretName])
17931862
}
17941863
setValue('')
17951864
setSaved(true)
@@ -2361,6 +2430,7 @@ function CredentialInputCard({
23612430
const upsertWorkspace = useUpsertWorkspaceEnvironment()
23622431
const savePersonal = useSavePersonalEnvironment()
23632432
const personalQuery = usePersonalEnvironment()
2433+
const attachDescriptions = useWorkspaceSecretDescriptions(data)
23642434
const [secretDrafts, setSecretDrafts] = useState<Record<number, string>>({})
23652435
const [savedSecretRows, setSavedSecretRows] = useState<Set<number>>(() => new Set())
23662436
const [connectedIntegrationRows, setConnectedIntegrationRows] = useState<Set<number>>(
@@ -2519,6 +2589,8 @@ function CredentialInputCard({
25192589
return false
25202590
}
25212591

2592+
await attachDescriptions(Object.keys(workspaceVariables))
2593+
25222594
const nextSavedSecretRows = new Set(savedSecretRows)
25232595
for (const index of enteredSecretIndexes) nextSavedSecretRows.add(index)
25242596
setSavedSecretRows(nextSavedSecretRows)

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -295,13 +295,25 @@ export const Browser: ToolCatalogEntry = {
295295
mode: 'async',
296296
parameters: {
297297
properties: {
298+
sessionId: {
299+
description:
300+
'Reusable session ID returned by an earlier browser call in this chat. Supply it only on a later user message that continues the same browsing objective, and at most once per user message.',
301+
type: 'string',
302+
},
298303
task: {
299304
description:
300-
'The web task to complete, in plain language (include the target site/URL if known).',
305+
"Optional brief scoping instruction that the conversation does not already convey. Do not restate the user's request.",
306+
type: 'string',
307+
},
308+
title: {
309+
description:
310+
"Required private orchestration label (3–8 words) for this Browser Agent session's stable objective. When resuming with sessionId, copy the registry title unchanged.",
311+
maxLength: 120,
312+
minLength: 1,
301313
type: 'string',
302314
},
303315
},
304-
required: ['task'],
316+
required: ['title'],
305317
type: 'object',
306318
},
307319
subagentId: 'browser',
@@ -1248,16 +1260,14 @@ export const Cp: ToolCatalogEntry = {
12481260
properties: {
12491261
destination: {
12501262
type: 'string',
1251-
maxLength: 4096,
12521263
description:
12531264
'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).',
12541265
},
12551266
sources: {
12561267
type: 'array',
1257-
maxItems: 100,
12581268
description:
12591269
'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.',
1260-
items: { type: 'string', maxLength: 4096 },
1270+
items: { type: 'string' },
12611271
},
12621272
toolTitle: {
12631273
type: 'string',
@@ -3716,10 +3726,9 @@ export const Mkdir: ToolCatalogEntry = {
37163726
properties: {
37173727
paths: {
37183728
type: 'array',
3719-
maxItems: 100,
37203729
description:
37213730
'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.',
3722-
items: { type: 'string', maxLength: 4096 },
3731+
items: { type: 'string' },
37233732
},
37243733
toolTitle: {
37253734
type: 'string',
@@ -3742,16 +3751,14 @@ export const Mv: ToolCatalogEntry = {
37423751
properties: {
37433752
destination: {
37443753
type: 'string',
3745-
maxLength: 4096,
37463754
description:
37473755
'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).',
37483756
},
37493757
sources: {
37503758
type: 'array',
3751-
maxItems: 100,
37523759
description:
37533760
'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.',
3754-
items: { type: 'string', maxLength: 4096 },
3761+
items: { type: 'string' },
37553762
},
37563763
toolTitle: {
37573764
type: 'string',
@@ -4184,10 +4191,9 @@ export const Rm: ToolCatalogEntry = {
41844191
properties: {
41854192
paths: {
41864193
type: 'array',
4187-
maxItems: 100,
41884194
description:
41894195
'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.',
4190-
items: { type: 'string', maxLength: 4096 },
4196+
items: { type: 'string' },
41914197
},
41924198
toolTitle: {
41934199
type: 'string',
@@ -4752,10 +4758,19 @@ export const SetEnvironmentVariables: ToolCatalogEntry = {
47524758
items: {
47534759
type: 'object',
47544760
properties: {
4761+
description: {
4762+
type: 'string',
4763+
description:
4764+
'What the variable is for, in one short phrase — aim for under 80 characters, like "Stripe live key for the billing workflow". Not a sentence, and never a restatement of the name. Workspace scope only; sending it with scope personal is rejected. Omit it on an existing variable to leave its current description untouched; send an empty string to clear one. You may send it alone, without a value, to describe a secret that already exists.',
4765+
},
47554766
name: { type: 'string', description: 'Variable name' },
4756-
value: { type: 'string', description: 'Variable value' },
4767+
value: {
4768+
type: 'string',
4769+
description:
4770+
"Variable value. Omit it to leave an existing variable's value untouched and change only its description — never invent or guess a value you were not given, which would overwrite the real secret.",
4771+
},
47574772
},
4758-
required: ['name', 'value'],
4773+
required: ['name'],
47594774
},
47604775
},
47614776
},

0 commit comments

Comments
 (0)