Skip to content

Commit 55e657f

Browse files
committed
fix(mothership): resolve Copilot env-reference passwords for chat deploy and file share
Copilot now deploys chats and shares files through the v2 API, which stored a whole-value {{NAME}} password literally (or 400'd a short one on length). The application use cases now resolve the reference from the effective environment when, and only when, the caller is an admitted Copilot workspace invocation and the password will actually be stored (password mode; for a file share, only while enabling it), refuse an unset variable by name, and hold the resolved value to the password rules. Every other principal keeps literal semantics. The v2 password fields admit a whole-value reference below the password minimum as one refined string (not a union, which would make the CLI flag JSON-only), still capped at the password maximum; the use cases enforce the 15-character rule on the value actually stored. Connector API-key references share the same principal environment lookup, and the exact reference regex now lives in one module. Unlike the removed Copilot tool path, the resolved password is not recorded in a resolved-secret trace registry: the v2 use case has none, and the password is write-only and never echoed back.
1 parent 558c77c commit 55e657f

22 files changed

Lines changed: 599 additions & 51 deletions

File tree

‎apps/docs/content/docs/cli/files.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ Enable or disable sharing for a file (OAuth login or personal API key required)
427427
| --- | --- | --- |
428428
| `--is-active <true\|false>` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. |
429429
| `--auth-type <value>` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. |
430-
| `--password <value>` | No | Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. |
430+
| `--password <value>` | No | Password of 15 to 1024 characters for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. Taken literally, except that a request from the Sim agent resolves a whole-value `&#123;&#123;ENV_VAR&#125;&#125;` reference to that variable before the rules apply. |
431431
| `--allowed-emails <value...>` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
432432

433433
</CommandTable>

‎apps/docs/content/docs/cli/reference.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,7 +1185,7 @@ sim files share set <fileId> [options]
11851185
| --- | --- | --- |
11861186
| `--is-active <true\|false>` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. |
11871187
| `--auth-type <value>` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. |
1188-
| `--password <value>` | No | Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. |
1188+
| `--password <value>` | No | Password of 15 to 1024 characters for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. Taken literally, except that a request from the Sim agent resolves a whole-value `&#123;&#123;ENV_VAR&#125;&#125;` reference to that variable before the rules apply. |
11891189
| `--allowed-emails <value...>` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
11901190

11911191
</CommandTable>
@@ -6469,7 +6469,7 @@ sim workflows chat publish <workflowId> [options]
64696469
| `--description <value>` | No | Description shown to visitors. Omitted clears it. |
64706470
| `--customizations <json\|@file>` | No | Presentation overrides. Omitted fields take platform defaults. (JSON, or @path / @- to read a file or stdin). |
64716471
| `--auth-type <value>` | No | How visitors are gated. `public` leaves the chat open to anyone holding the URL. Accepted values: `public`, `password`, `email`, `sso`. |
6472-
| `--password <value>` | No | Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back. |
6472+
| `--password <value>` | No | Write-only password of 15 to 1024 characters, not only whitespace. Required whenever `authType` is `password`, and rejected otherwise. Never readable back. Taken literally, except that a request from the Sim agent resolves a whole-value `&#123;&#123;ENV_VAR&#125;&#125;` reference to that variable before the rules apply. |
64736473
| `--allowed-emails <json\|@file>` | No | Email addresses or domains admitted under `email` and `sso` gating. At least one is required for those modes. (JSON, or @path / @- to read a file or stdin). |
64746474
| `--output-configs <json\|@file>` | No | Block outputs to surface to visitors. Omitted surfaces none. (JSON, or @path / @- to read a file or stdin). |
64756475
| `--include-thinking` | No | Allow visitors to receive provider thinking events. |

‎apps/docs/content/docs/cli/workflows.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ Publish or replace a workflow’s chat deployment (OAuth login or personal API k
441441
| `--description <value>` | No | Description shown to visitors. Omitted clears it. |
442442
| `--customizations <json\|@file>` | No | Presentation overrides. Omitted fields take platform defaults. (JSON, or @path / @- to read a file or stdin). |
443443
| `--auth-type <value>` | No | How visitors are gated. `public` leaves the chat open to anyone holding the URL. Accepted values: `public`, `password`, `email`, `sso`. |
444-
| `--password <value>` | No | Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back. |
444+
| `--password <value>` | No | Write-only password of 15 to 1024 characters, not only whitespace. Required whenever `authType` is `password`, and rejected otherwise. Never readable back. Taken literally, except that a request from the Sim agent resolves a whole-value `&#123;&#123;ENV_VAR&#125;&#125;` reference to that variable before the rules apply. |
445445
| `--allowed-emails <json\|@file>` | No | Email addresses or domains admitted under `email` and `sso` gating. At least one is required for those modes. (JSON, or @path / @- to read a file or stdin). |
446446
| `--output-configs <json\|@file>` | No | Block outputs to surface to visitors. Omitted surfaces none. (JSON, or @path / @- to read a file or stdin). |
447447
| `--include-thinking` | No | Allow visitors to receive provider thinking events. |

‎apps/docs/openapi-v2-files-audit.json‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5785,9 +5785,8 @@
57855785
"enum": ["public", "password", "email", "sso"]
57865786
},
57875787
"password": {
5788-
"description": "Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400.",
5788+
"description": "Password of 15 to 1024 characters for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. Taken literally, except that a request from the Sim agent resolves a whole-value `{{ENV_VAR}}` reference to that variable before the rules apply.",
57895789
"type": "string",
5790-
"minLength": 15,
57915790
"maxLength": 1024
57925791
},
57935792
"allowedEmails": {

‎apps/docs/openapi-v2-workflows.json‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11558,9 +11558,8 @@
1155811558
"enum": ["public", "password", "email", "sso"]
1155911559
},
1156011560
"password": {
11561-
"description": "Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back.",
11561+
"description": "Write-only password of 15 to 1024 characters, not only whitespace. Required whenever `authType` is `password`, and rejected otherwise. Never readable back. Taken literally, except that a request from the Sim agent resolves a whole-value `{{ENV_VAR}}` reference to that variable before the rules apply.",
1156211562
"type": "string",
11563-
"minLength": 1,
1156411563
"maxLength": 1024
1156511564
},
1156611565
"allowedEmails": {

‎apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
* @vitest-environment node
33
*/
44
import {
5+
environmentUtilsMockFns,
56
MockV2ApiKeyUnauthenticatedError,
67
resetDbChainMock,
8+
resetEnvironmentUtilsMock,
79
resetEnvMock,
810
setEnv,
911
V2_OPERATION_RATE_LIMIT_ALLOWED,
@@ -72,6 +74,8 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({
7274
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
7375
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
7476

77+
import { markCopilotRequest } from '@/lib/api/server/routes/copilot-request'
78+
import { performChatDeploy as realPerformChatDeploy } from '@/lib/workflows/orchestration/chat-deploy'
7579
import { DELETE, GET, PUT } from '@/app/api/v2/workflows/[workflowId]/deployments/chat/route'
7680

7781
const WORKSPACE_ID = 'workspace-1'
@@ -481,6 +485,91 @@ describe('/api/v2/workflows/[workflowId]/deployments/chat', () => {
481485

482486
expect(mocks.validateChatDeployAuth).not.toHaveBeenCalled()
483487
})
488+
489+
describe('password references', () => {
490+
const passwordBody = (password: string) => ({ ...validBody, authType: 'password', password })
491+
492+
/** Admitted exactly as the Sim agent's in-process CLI transport admits its calls. */
493+
const agentPut = (body: unknown) => {
494+
const request = new NextRequest(PATH, {
495+
method: 'PUT',
496+
headers: { 'content-type': 'application/json' },
497+
body: JSON.stringify(body),
498+
})
499+
markCopilotRequest(request, { userId: 'user-1', workspaceId: WORKSPACE_ID, chatId: 'c-1' })
500+
return PUT(request, routeContext)
501+
}
502+
503+
const environment = (variables: Record<string, string>) =>
504+
environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables.mockResolvedValueOnce(
505+
Object.fromEntries(
506+
Object.entries(variables).map(([name, value]) => [
507+
name,
508+
{ value, scope: 'workspace', visible: false },
509+
])
510+
)
511+
)
512+
513+
afterEach(resetEnvironmentUtilsMock)
514+
515+
it("deploys with the value of the agent's referenced variable", async () => {
516+
environment({ CHAT_PW: 'resolved-chat-password' })
517+
518+
const response = await agentPut(passwordBody('{{CHAT_PW}}'))
519+
520+
expect(response.status).toBe(200)
521+
expect(
522+
environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables
523+
).toHaveBeenCalledWith('user-1', WORKSPACE_ID, ['CHAT_PW'])
524+
expect(mocks.performChatDeploy.mock.calls[0][0].password).toBe('resolved-chat-password')
525+
})
526+
527+
it('refuses an unset variable by name instead of deploying the placeholder', async () => {
528+
const response = await agentPut(passwordBody('{{CHAT_PW}}'))
529+
530+
expect(response.status).toBe(400)
531+
expect((await response.json()).error.message).toBe(
532+
'Environment variable "CHAT_PW" referenced by password is not set for this workspace or user. Set it first, or pass the raw value.'
533+
)
534+
expect(mocks.performChatDeploy).not.toHaveBeenCalled()
535+
})
536+
537+
it('holds the resolved value to the chat password rules', async () => {
538+
mocks.performChatDeploy.mockImplementation(realPerformChatDeploy)
539+
environment({ CHAT_PW: 'short' })
540+
541+
const response = await agentPut(passwordBody('{{CHAT_PW}}'))
542+
543+
expect(response.status).toBe(400)
544+
expect((await response.json()).error.message).toBe(
545+
'Password must be at least 15 characters'
546+
)
547+
})
548+
549+
it('keeps a reference literal for an API key caller, under the same rules', async () => {
550+
mocks.performChatDeploy.mockImplementation(realPerformChatDeploy)
551+
552+
const response = await put(passwordBody('{{SHORT}}'))
553+
554+
expect(response.status).toBe(400)
555+
expect((await response.json()).error.message).toBe(
556+
'Password must be at least 15 characters'
557+
)
558+
expect(
559+
environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables
560+
).not.toHaveBeenCalled()
561+
})
562+
563+
it('stores a long literal reference verbatim for an API key caller', async () => {
564+
const response = await put(passwordBody('{{A_LONG_LITERAL_NAME}}'))
565+
566+
expect(response.status).toBe(200)
567+
expect(mocks.performChatDeploy.mock.calls[0][0].password).toBe('{{A_LONG_LITERAL_NAME}}')
568+
expect(
569+
environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables
570+
).not.toHaveBeenCalled()
571+
})
572+
})
484573
})
485574

486575
describe('DELETE', () => {

‎apps/sim/lib/api/contracts/chats.password.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
deployedChatPostBodySchema,
1010
updateChatBodySchema,
1111
} from '@/lib/api/contracts/chats'
12+
import { v2ReplaceChatDeploymentBodySchema } from '@/lib/api/contracts/v2/chat-deployments'
1213

1314
const createBody = {
1415
workflowId: 'wf-1',
@@ -72,4 +73,27 @@ describe('chat deployment password contract', () => {
7273
expect(updateChatBodySchema.safeParse({ password: tooLong }).success).toBe(false)
7374
expect(updateChatBodySchema.safeParse({ password: '' }).success).toBe(true)
7475
})
76+
77+
/**
78+
* Only the use case knows whether the caller is Sim's agent, whose reference
79+
* resolves before the password rules apply, so v2 admits a whole-value
80+
* reference below the password minimum. Every other short value, and the internal surface,
81+
* stay refused with the password message rather than a generic union failure.
82+
*/
83+
it('admits a short whole-value reference on v2 only', () => {
84+
const body = { identifier: 'support', title: 'Support', authType: 'password' }
85+
86+
expect(
87+
v2ReplaceChatDeploymentBodySchema.safeParse({ ...body, password: '{{PW}}' }).success
88+
).toBe(true)
89+
expect(
90+
v2ReplaceChatDeploymentBodySchema.safeParse({ ...body, password: 'x-{{PW}}' }).error
91+
?.issues[0].message
92+
).toBe('Password must be at least 15 characters')
93+
expect(
94+
v2ReplaceChatDeploymentBodySchema.safeParse({ ...body, password: ' ' }).error?.issues[0]
95+
.message
96+
).toBe('Password cannot contain only whitespace')
97+
expect(chatDeploymentPasswordSchema.safeParse('{{PW}}').success).toBe(false)
98+
})
7599
})

‎apps/sim/lib/api/contracts/primitives.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { isPlainRecord } from '@sim/utils/object'
22
import { z } from 'zod'
33
import { setRecordValue } from '@/lib/core/utils/records'
4+
import { EXACT_ENVIRONMENT_REFERENCE } from '@/lib/environment/reference'
45
import { PII_LANGUAGE_CODES, stripNerEntities } from '@/lib/guardrails/pii-entities'
56
import { validateRegexPattern } from '@/lib/guardrails/validate_regex'
67

@@ -142,6 +143,26 @@ export function flattenFieldErrors<TFields extends string>(
142143
export const noInputSchema = z.object({}).strict()
143144
export type NoInput = z.output<typeof noInputSchema>
144145

146+
/**
147+
* `literal`, or a whole-value `{{NAME}}` environment-variable reference that
148+
* `literal` would refuse. A refused non-reference reports `literal`'s own
149+
* messages. Built as one refined string rather than a union so the field keeps a
150+
* plain `string` shape in the generated OpenAPI and CLI, where a union becomes a
151+
* JSON-only flag. `literal`'s length cap bounds references too.
152+
*/
153+
export function orExactEnvironmentReference(literal: z.ZodString) {
154+
const capped =
155+
literal.maxLength === null
156+
? z.string()
157+
: z.string().max(literal.maxLength, { error: 'Password is too long', abort: true })
158+
return capped.superRefine((value, ctx) => {
159+
if (EXACT_ENVIRONMENT_REFERENCE.test(value)) return
160+
for (const issue of literal.safeParse(value).error?.issues ?? []) {
161+
ctx.addIssue({ code: 'custom', message: issue.message })
162+
}
163+
})
164+
}
165+
145166
/**
146167
* Accepts canonical RFC 4648 base64, including the empty encoding used for a
147168
* zero-byte file. Padding is required when the final quantum is incomplete,

‎apps/sim/lib/api/contracts/public-shares.password.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,32 @@ describe('public file share password contracts', () => {
3333
it('continues accepting legacy short passwords at the public login gate', () => {
3434
expect(authenticatePublicFileBodySchema.safeParse({ password: 'legacy' }).success).toBe(true)
3535
})
36+
37+
/**
38+
* Only the use case knows whether the caller is Sim's agent, whose reference
39+
* resolves before the password rules apply, so v2 admits a whole-value
40+
* reference below the password minimum. Every other short value, and the internal surface,
41+
* stay refused with the password message rather than a generic union failure.
42+
*/
43+
it('admits a short whole-value reference on v2 only', () => {
44+
const body = { workspaceId: 'workspace-1', isActive: true, authType: 'password' }
45+
46+
expect(v2UpsertFileShareBodySchema.safeParse({ ...body, password: '{{PW}}' }).success).toBe(
47+
true
48+
)
49+
expect(
50+
v2UpsertFileShareBodySchema.safeParse({ ...body, password: 'x-{{PW}}' }).error?.issues[0]
51+
.message
52+
).toBe('Password must be at least 15 characters')
53+
expect(sharePasswordSchema.safeParse('{{PW}}').success).toBe(false)
54+
})
55+
56+
it('caps a reference at the password length limit with one issue', () => {
57+
const body = { workspaceId: 'workspace-1', isActive: true, authType: 'password' }
58+
const issues = v2UpsertFileShareBodySchema.safeParse({
59+
...body,
60+
password: `{{${'A'.repeat(1024)}}}`,
61+
}).error?.issues
62+
expect(issues?.map((issue) => issue.message)).toEqual(['Password is too long'])
63+
})
3664
})

‎apps/sim/lib/api/contracts/v2/chat-deployments.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { chatAuthTypeSchema, chatDeploymentPasswordSchema } from '@/lib/api/cont
44
import {
55
booleanQueryFlagSchema,
66
noInputSchema,
7+
orExactEnvironmentReference,
78
workflowIdSchema,
89
workspaceIdSchema,
910
} from '@/lib/api/contracts/primitives'
@@ -346,11 +347,12 @@ export const v2ReplaceChatDeploymentBodySchema = z
346347
* make the verb non-idempotent from the caller's point of view — so a
347348
* password-gated result must state its password every time.
348349
*/
349-
password: chatDeploymentPasswordSchema
350-
.min(1, 'password cannot be empty')
350+
password: orExactEnvironmentReference(
351+
chatDeploymentPasswordSchema.min(1, 'password cannot be empty')
352+
)
351353
.optional()
352354
.describe(
353-
'Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back.'
355+
'Write-only password of 15 to 1024 characters, not only whitespace. Required whenever `authType` is `password`, and rejected otherwise. Never readable back. Taken literally, except that a request from the Sim agent resolves a whole-value `{{ENV_VAR}}` reference to that variable before the rules apply.'
354356
),
355357
allowedEmails: chatAllowedEmailsSchema
356358
.optional()

0 commit comments

Comments
 (0)