Skip to content

Commit 50285af

Browse files
committed
fix(copilot): update secret descriptions without values
1 parent 2618129 commit 50285af

4 files changed

Lines changed: 169 additions & 39 deletions

File tree

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4761,12 +4761,16 @@ export const SetEnvironmentVariables: ToolCatalogEntry = {
47614761
description: {
47624762
type: 'string',
47634763
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.',
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.',
47654765
},
47664766
name: { type: 'string', description: 'Variable name' },
4767-
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+
},
47684772
},
4769-
required: ['name', 'value'],
4773+
required: ['name'],
47704774
},
47714775
},
47724776
},

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4602,18 +4602,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
46024602
description: {
46034603
type: 'string',
46044604
description:
4605-
'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.',
4605+
'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.',
46064606
},
46074607
name: {
46084608
type: 'string',
46094609
description: 'Variable name',
46104610
},
46114611
value: {
46124612
type: 'string',
4613-
description: 'Variable value',
4613+
description:
4614+
"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.",
46144615
},
46154616
},
4616-
required: ['name', 'value'],
4617+
required: ['name'],
46174618
},
46184619
},
46194620
},

apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,22 @@ const {
1616
ensureWorkflowAccessMock,
1717
ensureWorkspaceAccessMock,
1818
getDefaultWorkspaceIdMock,
19-
setWorkspaceSecretMock,
19+
listCredentialsMock,
20+
performUpdateCredentialMock,
2021
} = vi.hoisted(() => ({
2122
ensureWorkflowAccessMock: vi.fn(),
2223
ensureWorkspaceAccessMock: vi.fn(),
2324
getDefaultWorkspaceIdMock: vi.fn(),
24-
setWorkspaceSecretMock: vi.fn(),
25+
listCredentialsMock: vi.fn(),
26+
performUpdateCredentialMock: vi.fn(),
2527
}))
2628

27-
vi.mock('@/lib/credentials/secret-values', () => ({
28-
setWorkspaceSecret: setWorkspaceSecretMock,
29+
vi.mock('@/lib/credentials/queries', () => ({
30+
listVisibleWorkspaceCredentials: listCredentialsMock,
31+
}))
32+
33+
vi.mock('@/lib/credentials/orchestration', () => ({
34+
performUpdateCredential: performUpdateCredentialMock,
2935
}))
3036

3137
vi.mock('@/lib/copilot/tools/handlers/access', () => ({
@@ -46,7 +52,13 @@ describe('setEnvironmentVariablesServerTool', () => {
4652
getDefaultWorkspaceIdMock.mockResolvedValue('ws-default')
4753
upsertPersonalEnvVarsMock.mockResolvedValue({ added: ['API_KEY'], updated: [] })
4854
upsertWorkspaceEnvVarsMock.mockResolvedValue(['API_KEY'])
49-
setWorkspaceSecretMock.mockResolvedValue({ created: false, updatedAt: new Date() })
55+
listCredentialsMock.mockResolvedValue({
56+
data: [
57+
{ id: 'cred-api', envKey: 'API_KEY' },
58+
{ id: 'cred-other', envKey: 'OTHER_KEY' },
59+
],
60+
})
61+
performUpdateCredentialMock.mockResolvedValue({ success: true })
5062
})
5163

5264
it('defaults to workspace scope and uses the current workspace context', async () => {
@@ -103,7 +115,7 @@ describe('setEnvironmentVariablesServerTool', () => {
103115
)
104116
})
105117

106-
it('describes a workspace secret through the same writer the Set Secret API uses', async () => {
118+
it('describes a workspace secret through the credential update handler, never rewriting its value', async () => {
107119
await setEnvironmentVariablesServerTool.execute(
108120
{
109121
variables: [
@@ -114,19 +126,58 @@ describe('setEnvironmentVariablesServerTool', () => {
114126
{ userId: 'user-1', workspaceId: 'ws-1' }
115127
)
116128

117-
expect(setWorkspaceSecretMock).toHaveBeenCalledTimes(1)
118-
expect(setWorkspaceSecretMock).toHaveBeenCalledWith({
119-
workspaceId: 'ws-1',
120-
name: 'API_KEY',
121-
value: 'secret',
129+
expect(performUpdateCredentialMock).toHaveBeenCalledTimes(1)
130+
expect(performUpdateCredentialMock).toHaveBeenCalledWith({
131+
credentialId: 'cred-api',
122132
userId: 'user-1',
123133
description: 'Stripe live key',
134+
allowedTypes: ['env_workspace'],
124135
})
125-
// The access-checked bulk write still runs first: it is what authorizes the
126-
// caller and mints the credential row a new key's description needs.
136+
// The access-checked value write runs first: it authorizes the caller and
137+
// mints the credential row a new key's description hangs on.
127138
expect(upsertWorkspaceEnvVarsMock.mock.invocationCallOrder[0]).toBeLessThan(
128-
setWorkspaceSecretMock.mock.invocationCallOrder[0]
139+
performUpdateCredentialMock.mock.invocationCallOrder[0]
140+
)
141+
})
142+
143+
it('describes a secret that already exists without touching its value', async () => {
144+
const result = await setEnvironmentVariablesServerTool.execute(
145+
{ variables: [{ name: 'API_KEY', description: 'Stripe live key' }] },
146+
{ userId: 'user-1', workspaceId: 'ws-1' }
147+
)
148+
149+
// Nothing is written to the secret itself: coercing the absent value to ''
150+
// would blank the very secret the model is annotating.
151+
expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', {}, 'user-1')
152+
expect(performUpdateCredentialMock).toHaveBeenCalledWith(
153+
expect.objectContaining({ credentialId: 'cred-api', description: 'Stripe live key' })
154+
)
155+
expect(result.describedVariables).toEqual(['API_KEY'])
156+
})
157+
158+
it('keeps a stored value reported when its description fails', async () => {
159+
performUpdateCredentialMock.mockResolvedValue({ success: false, error: 'Forbidden' })
160+
161+
const result = await setEnvironmentVariablesServerTool.execute(
162+
{ variables: [{ name: 'API_KEY', value: 'secret', description: 'Stripe live key' }] },
163+
{ userId: 'user-1', workspaceId: 'ws-1' }
129164
)
165+
166+
expect(result.workspaceUpdatedVariables).toEqual(['API_KEY'])
167+
expect(result.describedVariables).toEqual([])
168+
expect(result.message).toContain('API_KEY: Forbidden')
169+
})
170+
171+
it('fails a describe-only call that saved nothing', async () => {
172+
performUpdateCredentialMock.mockResolvedValue({ success: false, error: 'Forbidden' })
173+
upsertWorkspaceEnvVarsMock.mockResolvedValue([])
174+
175+
await expect(
176+
setEnvironmentVariablesServerTool.execute(
177+
{ variables: [{ name: 'API_KEY', description: 'Stripe live key' }] },
178+
{ userId: 'user-1', workspaceId: 'ws-1' }
179+
)
180+
).rejects.toThrow('Could not describe: API_KEY: Forbidden')
130181
})
131182

132183
it('clears a description sent blank and leaves an omitted one alone', async () => {
@@ -140,9 +191,9 @@ describe('setEnvironmentVariablesServerTool', () => {
140191
{ userId: 'user-1', workspaceId: 'ws-1' }
141192
)
142193

143-
expect(setWorkspaceSecretMock).toHaveBeenCalledTimes(1)
144-
expect(setWorkspaceSecretMock).toHaveBeenCalledWith(
145-
expect.objectContaining({ name: 'API_KEY', description: null })
194+
expect(performUpdateCredentialMock).toHaveBeenCalledTimes(1)
195+
expect(performUpdateCredentialMock).toHaveBeenCalledWith(
196+
expect.objectContaining({ credentialId: 'cred-api', description: null })
146197
)
147198
})
148199

apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
getDefaultWorkspaceId,
88
} from '@/lib/copilot/tools/handlers/access'
99
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
10-
import { setWorkspaceSecret } from '@/lib/credentials/secret-values'
10+
import { performUpdateCredential } from '@/lib/credentials/orchestration'
11+
import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries'
1112
import { upsertPersonalEnvVars, upsertWorkspaceEnvVars } from '@/lib/environment/utils'
1213

1314
type EnvironmentVariableInputValue = string | number | boolean | null | undefined
@@ -37,10 +38,20 @@ interface SetEnvironmentVariablesResult {
3738
addedVariables: string[]
3839
updatedVariables: string[]
3940
workspaceUpdatedVariables: string[]
41+
describedVariables: string[]
4042
}
4143

4244
const EnvVarSchema = z.object({ variables: z.record(z.string(), z.string()) })
4345

46+
/** A row that only annotates a secret that already exists — it sends no value. */
47+
function isDescriptionOnly(item: EnvironmentVariableInput): boolean {
48+
return (
49+
(item.value === undefined || item.value === null) &&
50+
typeof item.description === 'string' &&
51+
item.description.trim().length > 0
52+
)
53+
}
54+
4455
/**
4556
* Collects the descriptions the model actually sent. A variable that omits the
4657
* field is absent from the result, so an existing description survives a value
@@ -65,13 +76,18 @@ function normalizeDescriptions(
6576
return descriptions
6677
}
6778

79+
/**
80+
* Values to write. An array item that carries a description but no value is a
81+
* description-only edit and is deliberately absent here: coercing its missing
82+
* value to `''` would blank the very secret the model is trying to annotate.
83+
*/
6884
function normalizeVariables(
6985
input: Record<string, EnvironmentVariableInputValue> | EnvironmentVariableInput[]
7086
): Record<string, string> {
7187
if (Array.isArray(input)) {
7288
return input.reduce(
7389
(acc, item) => {
74-
if (item && typeof item.name === 'string') {
90+
if (item && typeof item.name === 'string' && !isDescriptionOnly(item)) {
7591
acc[item.name] = String(item.value ?? '')
7692
}
7793
return acc
@@ -84,6 +100,57 @@ function normalizeVariables(
84100
) as Record<string, string>
85101
}
86102

103+
/**
104+
* Writes descriptions onto secrets, never their values. Resolves each key to its
105+
* credential row and hands it to `performUpdateCredential` — the same handler the
106+
* secrets settings page calls — so credential-admin access, the `env_personal`
107+
* refusal, and the audit record are all decided in one place.
108+
*
109+
* Only the `description` column is touched. Re-sending the value to attach a note
110+
* would clobber a rotation that landed between the two writes, and the note is
111+
* never worth losing someone else's secret over.
112+
*/
113+
async function describeSecrets(params: {
114+
workspaceId: string
115+
userId: string
116+
descriptions: Record<string, string | null>
117+
}): Promise<{ described: string[]; failures: string[] }> {
118+
const names = Object.keys(params.descriptions)
119+
if (names.length === 0) return { described: [], failures: [] }
120+
121+
const { data: credentials } = await listVisibleWorkspaceCredentials({
122+
workspaceId: params.workspaceId,
123+
userId: params.userId,
124+
workspaceAccess: { canAdmin: false },
125+
types: ['env_workspace'],
126+
})
127+
const idByEnvKey = new Map(
128+
credentials.flatMap((row) => (row.envKey ? [[row.envKey, row.id] as const] : []))
129+
)
130+
131+
const described: string[] = []
132+
const failures: string[] = []
133+
for (const name of names) {
134+
const credentialId = idByEnvKey.get(name)
135+
if (!credentialId) {
136+
failures.push(`no workspace secret named ${name}`)
137+
continue
138+
}
139+
const result = await performUpdateCredential({
140+
credentialId,
141+
userId: params.userId,
142+
description: params.descriptions[name],
143+
allowedTypes: ['env_workspace'],
144+
})
145+
if (result.success) {
146+
described.push(name)
147+
} else {
148+
failures.push(`${name}: ${result.error ?? 'could not be described'}`)
149+
}
150+
}
151+
return { described, failures }
152+
}
153+
87154
async function resolveWorkspaceId(
88155
params: SetEnvironmentVariablesParams,
89156
context: ServerToolContext | undefined,
@@ -142,6 +209,8 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<
142209
const added: string[] = []
143210
const updated: string[] = []
144211
let workspaceUpdated: string[] = []
212+
let described: string[] = []
213+
let descriptionFailures: string[] = []
145214

146215
let resolvedWorkspaceId: string | undefined
147216
if (scope === 'workspace') {
@@ -151,20 +220,15 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<
151220
validatedVariables,
152221
authenticatedUserId
153222
)
154-
// Same writer the Set Secret use case calls, so the description lands with
155-
// the semantics the secrets UI and API already define. It runs second
156-
// because a brand-new key has no credential row to describe until the
157-
// upsert above has minted one — and only for described keys, so a plain
158-
// rotation stays a single write.
159-
for (const [name, description] of Object.entries(descriptions)) {
160-
await setWorkspaceSecret({
161-
workspaceId: resolvedWorkspaceId,
162-
name,
163-
value: validatedVariables[name],
164-
userId: authenticatedUserId,
165-
description,
166-
})
167-
}
223+
// Runs after the value write, which is what mints the credential row a
224+
// brand-new key's description hangs on.
225+
const outcome = await describeSecrets({
226+
workspaceId: resolvedWorkspaceId,
227+
userId: authenticatedUserId,
228+
descriptions,
229+
})
230+
described = outcome.described
231+
descriptionFailures = outcome.failures
168232
} else {
169233
const result = await upsertPersonalEnvVars(authenticatedUserId, validatedVariables)
170234
added.push(...result.added)
@@ -182,11 +246,20 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<
182246
workspaceId: resolvedWorkspaceId,
183247
})
184248

249+
// A failed description never fails a stored value — but a describe-only call
250+
// has nothing else to report, so its failure is the result.
251+
if (descriptionFailures.length > 0 && workspaceUpdated.length === 0) {
252+
throw new Error(`Could not describe: ${descriptionFailures.join('; ')}`)
253+
}
254+
185255
const parts: string[] = []
186256
if (added.length > 0) parts.push(`${added.length} personal secret(s) added`)
187257
if (updated.length > 0) parts.push(`${updated.length} personal secret(s) updated`)
188258
if (workspaceUpdated.length > 0)
189259
parts.push(`${workspaceUpdated.length} workspace secret(s) updated`)
260+
if (described.length > 0) parts.push(`${described.length} description(s) saved`)
261+
if (descriptionFailures.length > 0)
262+
parts.push(`descriptions not saved (${descriptionFailures.join('; ')})`)
190263

191264
return {
192265
message: `Successfully processed ${totalProcessed} secret(s): ${parts.join(', ')}`,
@@ -197,6 +270,7 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<
197270
addedVariables: added,
198271
updatedVariables: updated,
199272
workspaceUpdatedVariables: workspaceUpdated,
273+
describedVariables: described,
200274
}
201275
},
202276
}

0 commit comments

Comments
 (0)