Skip to content

Commit 6ec8f56

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/deploy-requires-write
2 parents d15ea3c + 7f39678 commit 6ec8f56

16 files changed

Lines changed: 207 additions & 80 deletions

File tree

apps/desktop/src/main/menu.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,9 @@ describe('buildMenuTemplate', () => {
9090
])
9191
})
9292

93-
it('keeps Help limited to documentation and system status', () => {
93+
it('keeps Help limited to documentation and Sim status', () => {
9494
const help = submenu(buildMenuTemplate(makeDeps()), 'Help')
95-
expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'System Status'])
95+
expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'Sim Status'])
9696
})
9797

9898
it('never exposes developer tools in the application menu', () => {

apps/desktop/src/main/menu.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
211211
click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()),
212212
},
213213
{
214-
label: 'System Status',
214+
label: 'Sim Status',
215215
click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()),
216216
},
217217
],

apps/docs/openapi-v2-workflows.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,7 +1021,7 @@
10211021
"post": {
10221022
"operationId": "executeWorkflowV2",
10231023
"summary": "Execute Workflow",
1024-
"description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. The optional `X-Run-Id` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with `error.details.code: \"RUN_ID_CONFLICT\"` and never replays the earlier run. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
1024+
"description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. The optional `X-Run-Id` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with `error.details.code: \"RUN_ID_CONFLICT\"` and never replays the earlier run. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
10251025
"tags": ["Workflows"],
10261026
"security": [
10271027
{
@@ -1069,7 +1069,7 @@
10691069
],
10701070
"requestBody": {
10711071
"required": true,
1072-
"description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
1072+
"description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
10731073
"content": {
10741074
"application/json": {
10751075
"schema": {
@@ -3861,12 +3861,12 @@
38613861
},
38623862
"includeThinking": {
38633863
"default": false,
3864-
"description": "Include model reasoning events in an agent-event stream. Requires the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.",
3864+
"description": "Include model reasoning events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.",
38653865
"type": "boolean"
38663866
},
38673867
"includeToolCalls": {
38683868
"default": false,
3869-
"description": "Include tool-call events in an agent-event stream. Requires the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.",
3869+
"description": "Include tool-call events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.",
38703870
"type": "boolean"
38713871
},
38723872
"includeFileBase64": {
@@ -3882,7 +3882,7 @@
38823882
},
38833883
"additionalProperties": false,
38843884
"title": "Execute workflow request",
3885-
"description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
3885+
"description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.",
38863886
"examples": [
38873887
{
38883888
"input": {

apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { setEnv } from '@sim/testing'
45
import { NextRequest } from 'next/server'
56
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
67
import { MANIFEST_ASSET_NAME } from '@/lib/desktop/update-feed'
@@ -27,8 +28,11 @@ function manifest(version: string) {
2728
return [`version: ${version}`, 'files:', ` - url: Sim-${version}-universal-mac.zip`].join('\n')
2829
}
2930

30-
async function getFeed(hostname: string): Promise<Response> {
31-
return GET(new NextRequest(`https://${hostname}/api/desktop/update/latest-mac.yml`), undefined)
31+
async function getFeed(hostname: string, headers?: HeadersInit): Promise<Response> {
32+
return GET(
33+
new NextRequest(`https://${hostname}/api/desktop/update/latest-mac.yml`, { headers }),
34+
undefined
35+
)
3236
}
3337

3438
describe('desktop update manifest route', () => {
@@ -37,17 +41,19 @@ describe('desktop update manifest route', () => {
3741
beforeEach(() => {
3842
fetchMock.mockReset()
3943
vi.stubGlobal('fetch', fetchMock)
44+
setEnv({ APPCONFIG_ENVIRONMENT: undefined })
4045
})
4146

4247
afterEach(() => {
4348
vi.unstubAllGlobals()
4449
})
4550

4651
it.each([
47-
['www.dev.sim.ai', 'v1.2.0-dev.4', '1.2.0-dev.4'],
48-
['www.staging.sim.ai', 'v1.2.0-staging.5', '1.2.0-staging.5'],
49-
['www.sim.ai', 'v1.1.0', '1.1.0'],
50-
])('serves the newest release for %s', async (hostname, tag, version) => {
52+
['dev', 'v1.2.0-dev.4', '1.2.0-dev.4'],
53+
['staging', 'v1.2.0-staging.5', '1.2.0-staging.5'],
54+
['production', 'v1.1.0', '1.1.0'],
55+
])('serves the newest release for the %s deployment', async (environment, tag, version) => {
56+
setEnv({ APPCONFIG_ENVIRONMENT: environment })
5157
fetchMock.mockImplementation(async (input: string | URL | Request) => {
5258
const url = String(input)
5359
if (url === RELEASES_URL) {
@@ -63,7 +69,7 @@ describe('desktop update manifest route', () => {
6369
return new Response(null, { status: 404 })
6470
})
6571

66-
const response = await getFeed(hostname)
72+
const response = await getFeed('internal.service.local')
6773
const body = await response.text()
6874

6975
expect(response.status).toBe(200)
@@ -74,6 +80,62 @@ describe('desktop update manifest route', () => {
7480
)
7581
})
7682

83+
it.each([
84+
['dev', 'www.staging.sim.ai:443', 'v1.2.0-dev.4', '1.2.0-dev.4'],
85+
['staging', 'www.sim.ai:443', 'v1.2.0-staging.5', '1.2.0-staging.5'],
86+
['production', 'www.dev.sim.ai:443', 'v1.1.0', '1.1.0'],
87+
])(
88+
'ignores request-controlled host headers for the %s deployment',
89+
async (environment, spoofedHost, tag, version) => {
90+
setEnv({ APPCONFIG_ENVIRONMENT: environment })
91+
fetchMock.mockImplementation(async (input: string | URL | Request) => {
92+
const url = String(input)
93+
if (url === RELEASES_URL) {
94+
return Response.json([
95+
release('v1.2.0-dev.4'),
96+
release('v1.2.0-staging.5'),
97+
release('v1.1.0'),
98+
])
99+
}
100+
if (url === `https://downloads.example/${tag}/${MANIFEST_ASSET_NAME}`) {
101+
return new Response(manifest(version))
102+
}
103+
return new Response(null, { status: 404 })
104+
})
105+
106+
const response = await getFeed('internal.service.local', {
107+
host: spoofedHost,
108+
'x-forwarded-host': `attacker.example, ${spoofedHost}`,
109+
})
110+
const body = await response.text()
111+
112+
expect(response.status).toBe(200)
113+
expect(response.headers.get(FEED_STATUS_HEADER)).toBe('release')
114+
expect(body).toContain(`version: ${version}`)
115+
}
116+
)
117+
118+
it('defaults self-hosted deployments to the stable channel', async () => {
119+
fetchMock.mockImplementation(async (input: string | URL | Request) => {
120+
const url = String(input)
121+
if (url === RELEASES_URL) {
122+
return Response.json([release('v1.2.0-dev.4'), release('v1.1.0')])
123+
}
124+
if (url === `https://downloads.example/v1.1.0/${MANIFEST_ASSET_NAME}`) {
125+
return new Response(manifest('1.1.0'))
126+
}
127+
return new Response(null, { status: 404 })
128+
})
129+
130+
const response = await getFeed('internal.service.local', {
131+
host: 'www.dev.sim.ai:443',
132+
'x-forwarded-host': 'www.dev.sim.ai:443',
133+
})
134+
135+
expect(response.status).toBe(200)
136+
expect(await response.text()).toContain('version: 1.1.0')
137+
})
138+
77139
it('reports an authoritative no-release result for production with only prereleases', async () => {
78140
fetchMock.mockResolvedValueOnce(
79141
Response.json([release('v1.2.0-dev.4'), release('v1.2.0-staging.5')])
@@ -88,6 +150,7 @@ describe('desktop update manifest route', () => {
88150
})
89151

90152
it('rejects a manifest whose version does not match its selected release', async () => {
153+
setEnv({ APPCONFIG_ENVIRONMENT: 'dev' })
91154
fetchMock
92155
.mockResolvedValueOnce(Response.json([release('v1.2.0-dev.4')]))
93156
.mockResolvedValueOnce(new Response(manifest('1.2.0-staging.5')))

apps/sim/app/api/desktop/update/latest-mac.yml/route.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
3+
import { env } from '@/lib/core/config/env'
34
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
45
import {
5-
channelForHostname,
6+
channelForDeploymentEnvironment,
67
DESKTOP_RELEASE_REPO,
78
type DesktopReleaseCandidate,
89
MANIFEST_ASSET_NAME,
@@ -29,12 +30,14 @@ const RELEASES_API_URL = `https://api.github.com/repos/${DESKTOP_RELEASE_REPO}/r
2930
* design: the updater's HTTP client carries no session, and the response
3031
* only describes public GitHub release artifacts.
3132
*/
32-
export const GET = withRouteHandler(async (request: NextRequest): Promise<Response> => {
33-
// The same deployment configuration can be promoted across environments, so
34-
// its baked NEXT_PUBLIC_APP_URL is not authoritative for this public feed.
35-
// The hostname the installed shell actually requested is the channel:
36-
// dev -> dev, staging -> staging, and prod/self-hosted -> stable.
37-
const channel = channelForHostname(request.nextUrl.hostname)
33+
export const GET = withRouteHandler(async (_request: NextRequest): Promise<Response> => {
34+
/**
35+
* Hosted deployments inject APPCONFIG_ENVIRONMENT independently at runtime,
36+
* so it stays correct when the same image is promoted across environments.
37+
* Request host headers are intentionally excluded: this public route must not
38+
* let a caller choose which app-identity release the feed serves.
39+
*/
40+
const channel = channelForDeploymentEnvironment(env.APPCONFIG_ENVIRONMENT)
3841

3942
// A token raises the GitHub API quota from 60/h per NAT IP to 5000/h.
4043
// Optional: the repo is public, so the feed works without one.

apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,27 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
408408
expect(mockPreprocessExecution).not.toHaveBeenCalled()
409409
})
410410

411+
it.each(['includeThinking', 'includeToolCalls'])(
412+
'rejects %s unless stream is true before checking the protocol header',
413+
async (option) => {
414+
const withProtocol = await callExecute(
415+
{ [option]: true },
416+
{ 'X-Sim-Stream-Protocol': 'agent-events-v1' }
417+
)
418+
const withoutProtocol = await callExecute({ [option]: true })
419+
420+
expect(withProtocol.status).toBe(400)
421+
expect((await withProtocol.json()).error.message).toBe(
422+
'includeThinking and includeToolCalls require stream: true'
423+
)
424+
expect(withoutProtocol.status).toBe(400)
425+
expect((await withoutProtocol.json()).error.message).toBe(
426+
'includeThinking and includeToolCalls require stream: true'
427+
)
428+
expect(mockPreprocessExecution).not.toHaveBeenCalled()
429+
}
430+
)
431+
411432
it('conceals a workspace-key/workflow mismatch as not found', async () => {
412433
mockAuthenticateV2ApiKey.mockResolvedValue({
413434
principal: {

apps/sim/app/api/v2/workflows/[id]/execute/route.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,14 @@ export const POST = withRouteHandler(
216216
'Async execution does not support streaming or output-shaping options'
217217
)
218218
}
219-
if (
220-
hasAgentStreamPolicy({
221-
includeThinking: body.includeThinking,
222-
includeToolCalls: body.includeToolCalls,
223-
}) &&
224-
!clientAcceptsAgentStreamProtocol(req.headers)
225-
) {
219+
const hasAgentStreamOptions = hasAgentStreamPolicy({
220+
includeThinking: body.includeThinking,
221+
includeToolCalls: body.includeToolCalls,
222+
})
223+
if (hasAgentStreamOptions && !body.stream) {
224+
return v2Error('BAD_REQUEST', 'includeThinking and includeToolCalls require stream: true')
225+
}
226+
if (hasAgentStreamOptions && !clientAcceptsAgentStreamProtocol(req.headers)) {
226227
return v2Error(
227228
'BAD_REQUEST',
228229
`includeThinking and includeToolCalls require the ${AGENT_STREAM_PROTOCOL_HEADER_LABEL}: ${AGENT_STREAM_PROTOCOL_V1} request header, which declares that the client understands agent-event frames.`

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,10 +686,13 @@ export function AddResourceDropdown({
686686
filtered.length > 0 ? (
687687
filtered.map(({ type, item }, index) => {
688688
const config = getResourceConfig(type)
689+
/* The search box keeps focus, so rows never take DOM focus and the menu's
690+
own `focus:` highlight never fires — `activeIndex` is this list's
691+
cursor, so it paints the hover surface rather than the selected one. */
689692
return (
690693
<DropdownMenuItem
691694
key={`${type}:${item.id}`}
692-
className={cn(index === activeIndex && 'bg-[var(--surface-active)]')}
695+
className={cn(index === activeIndex && 'bg-[var(--surface-hover)]')}
693696
onMouseEnter={() => setActiveIndex(index)}
694697
onClick={() => select({ type, id: item.id, title: item.name })}
695698
>

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,8 +345,9 @@ export const PlusMenuDropdown = React.memo(
345345
handleSelect({ type, id: item.id, title: item.name })
346346
}}
347347
className={cn(
348-
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
349-
isActive && 'bg-[var(--surface-active)]'
348+
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors duration-0 [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
349+
/* `activeIndex` is the cursor, not a selection — hover surface. */
350+
isActive && 'bg-[var(--surface-hover)]'
350351
)}
351352
>
352353
{config.renderDropdownItem({ item })}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,9 @@ export const SkillsMenuDropdown = React.memo(
210210
onMouseEnter={() => setActiveIndex(index)}
211211
onClick={() => handleSelect(target)}
212212
className={cn(
213-
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
214-
isActive && 'bg-[var(--surface-active)]'
213+
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors duration-0 [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
214+
/* `activeIndex` is the cursor, not a selection — hover surface. */
215+
isActive && 'bg-[var(--surface-hover)]'
215216
)}
216217
>
217218
{target.kind === 'skill' ? <AgentSkillsIcon /> : <McpIcon />}

0 commit comments

Comments
 (0)