From 1ee4d14ff29bdcb5e8e75fe9e6946de14bc6af09 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 11 Aug 2026 18:22:05 -0700 Subject: [PATCH 1/3] Fix update check --- .../update/latest-mac.yml/route.test.ts | 29 +++++++++++++++++-- .../desktop/update/latest-mac.yml/route.ts | 25 ++++++++++++---- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts index 2c4c89adadb..e9a20a1ca31 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts @@ -27,8 +27,11 @@ function manifest(version: string) { return [`version: ${version}`, 'files:', ` - url: Sim-${version}-universal-mac.zip`].join('\n') } -async function getFeed(hostname: string): Promise { - return GET(new NextRequest(`https://${hostname}/api/desktop/update/latest-mac.yml`), undefined) +async function getFeed(hostname: string, headers?: HeadersInit): Promise { + return GET( + new NextRequest(`https://${hostname}/api/desktop/update/latest-mac.yml`, { headers }), + undefined + ) } describe('desktop update manifest route', () => { @@ -74,6 +77,28 @@ describe('desktop update manifest route', () => { ) }) + it('uses the forwarded public hostname behind a reverse proxy', async () => { + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = String(input) + if (url === RELEASES_URL) { + return Response.json([release('v1.2.0-staging.5'), release('v1.1.0')]) + } + if (url === `https://downloads.example/v1.2.0-staging.5/${MANIFEST_ASSET_NAME}`) { + return new Response(manifest('1.2.0-staging.5')) + } + return new Response(null, { status: 404 }) + }) + + const response = await getFeed('internal.service.local', { + 'x-forwarded-host': 'www.staging.sim.ai:443, internal.service.local', + }) + const body = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get(FEED_STATUS_HEADER)).toBe('release') + expect(body).toContain('version: 1.2.0-staging.5') + }) + it('reports an authoritative no-release result for production with only prereleases', async () => { fetchMock.mockResolvedValueOnce( Response.json([release('v1.2.0-dev.4'), release('v1.2.0-staging.5')]) diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts index 897589a5b00..4b128e31e82 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts @@ -21,6 +21,19 @@ const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' const RELEASES_API_URL = `https://api.github.com/repos/${DESKTOP_RELEASE_REPO}/releases?per_page=30` +/** Resolves the public hostname when Next.js is running behind a reverse proxy. */ +function hostnameForRequest(request: NextRequest): string { + const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim() + const host = forwardedHost || request.headers.get('host') + if (!host) return request.nextUrl.hostname + + try { + return new URL(`http://${host}`).hostname + } catch { + return request.nextUrl.hostname + } +} + /** * The per-environment desktop update feed (see `lib/desktop/update-feed.ts`). * @@ -30,11 +43,13 @@ const RELEASES_API_URL = `https://api.github.com/repos/${DESKTOP_RELEASE_REPO}/r * only describes public GitHub release artifacts. */ export const GET = withRouteHandler(async (request: NextRequest): Promise => { - // The same deployment configuration can be promoted across environments, so - // its baked NEXT_PUBLIC_APP_URL is not authoritative for this public feed. - // The hostname the installed shell actually requested is the channel: - // dev -> dev, staging -> staging, and prod/self-hosted -> stable. - const channel = channelForHostname(request.nextUrl.hostname) + /** + * The same deployment configuration can be promoted across environments, so + * its baked NEXT_PUBLIC_APP_URL is not authoritative for this public feed. + * Reverse proxies replace the request URL's hostname with their internal + * origin, so use the forwarded public host to select the channel. + */ + const channel = channelForHostname(hostnameForRequest(request)) // A token raises the GitHub API quota from 60/h per NAT IP to 5000/h. // Optional: the repo is public, so the feed works without one. From d8fcca52eba40d475e51d860945ab87da205f394 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 11 Aug 2026 18:29:23 -0700 Subject: [PATCH 2/3] fix include tool calls error --- apps/docs/openapi-v2-workflows.json | 10 ++-- .../update/latest-mac.yml/route.test.ts | 47 +++++++++++++++---- .../v2/workflows/[id]/execute/route.test.ts | 21 +++++++++ .../api/v2/workflows/[id]/execute/route.ts | 15 +++--- .../lib/api/contracts/v2/openapi/workflows.ts | 4 +- apps/sim/lib/api/contracts/v2/workflows.ts | 8 ++-- 6 files changed, 79 insertions(+), 26 deletions(-) diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 34b88ebdbd8..cdc211333bd 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1021,7 +1021,7 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute Workflow", - "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.", + "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.", "tags": ["Workflows"], "security": [ { @@ -1069,7 +1069,7 @@ ], "requestBody": { "required": true, - "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.", + "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.", "content": { "application/json": { "schema": { @@ -3861,12 +3861,12 @@ }, "includeThinking": { "default": false, - "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.", + "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.", "type": "boolean" }, "includeToolCalls": { "default": false, - "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.", + "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.", "type": "boolean" }, "includeFileBase64": { @@ -3882,7 +3882,7 @@ }, "additionalProperties": false, "title": "Execute workflow request", - "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.", + "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.", "examples": [ { "input": { diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts index e9a20a1ca31..53e8447b33c 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts @@ -77,26 +77,57 @@ describe('desktop update manifest route', () => { ) }) - it('uses the forwarded public hostname behind a reverse proxy', async () => { + it.each([ + ['dev', 'www.dev.sim.ai:443', 'v1.2.0-dev.4', '1.2.0-dev.4'], + ['staging', 'www.staging.sim.ai:443', 'v1.2.0-staging.5', '1.2.0-staging.5'], + ['production', 'www.sim.ai:443', 'v1.1.0', '1.1.0'], + ])( + 'uses the forwarded public hostname for %s behind a reverse proxy', + async (_, host, tag, version) => { + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = String(input) + if (url === RELEASES_URL) { + return Response.json([ + release('v1.2.0-dev.4'), + release('v1.2.0-staging.5'), + release('v1.1.0'), + ]) + } + if (url === `https://downloads.example/${tag}/${MANIFEST_ASSET_NAME}`) { + return new Response(manifest(version)) + } + return new Response(null, { status: 404 }) + }) + + const response = await getFeed('internal.service.local', { + 'x-forwarded-host': `${host}, internal.service.local`, + }) + const body = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get(FEED_STATUS_HEADER)).toBe('release') + expect(body).toContain(`version: ${version}`) + } + ) + + it('falls back to the Host header when no forwarded host is present', async () => { fetchMock.mockImplementation(async (input: string | URL | Request) => { const url = String(input) if (url === RELEASES_URL) { - return Response.json([release('v1.2.0-staging.5'), release('v1.1.0')]) + return Response.json([release('v1.2.0-dev.4'), release('v1.1.0')]) } - if (url === `https://downloads.example/v1.2.0-staging.5/${MANIFEST_ASSET_NAME}`) { - return new Response(manifest('1.2.0-staging.5')) + if (url === `https://downloads.example/v1.2.0-dev.4/${MANIFEST_ASSET_NAME}`) { + return new Response(manifest('1.2.0-dev.4')) } return new Response(null, { status: 404 }) }) const response = await getFeed('internal.service.local', { - 'x-forwarded-host': 'www.staging.sim.ai:443, internal.service.local', + host: 'www.dev.sim.ai:443', }) - const body = await response.text() expect(response.status).toBe(200) - expect(response.headers.get(FEED_STATUS_HEADER)).toBe('release') - expect(body).toContain('version: 1.2.0-staging.5') + expect(await response.text()).toContain('version: 1.2.0-dev.4') }) it('reports an authoritative no-release result for production with only prereleases', async () => { diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts index 015166c6e1f..9d5f5aeeb17 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -408,6 +408,27 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(mockPreprocessExecution).not.toHaveBeenCalled() }) + it.each(['includeThinking', 'includeToolCalls'])( + 'rejects %s unless stream is true before checking the protocol header', + async (option) => { + const withProtocol = await callExecute( + { [option]: true }, + { 'X-Sim-Stream-Protocol': 'agent-events-v1' } + ) + const withoutProtocol = await callExecute({ [option]: true }) + + expect(withProtocol.status).toBe(400) + expect((await withProtocol.json()).error.message).toBe( + 'includeThinking and includeToolCalls require stream: true' + ) + expect(withoutProtocol.status).toBe(400) + expect((await withoutProtocol.json()).error.message).toBe( + 'includeThinking and includeToolCalls require stream: true' + ) + expect(mockPreprocessExecution).not.toHaveBeenCalled() + } + ) + it('conceals a workspace-key/workflow mismatch as not found', async () => { mockAuthenticateV2ApiKey.mockResolvedValue({ principal: { diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 853f6174944..b9fe5eab1e9 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -216,13 +216,14 @@ export const POST = withRouteHandler( 'Async execution does not support streaming or output-shaping options' ) } - if ( - hasAgentStreamPolicy({ - includeThinking: body.includeThinking, - includeToolCalls: body.includeToolCalls, - }) && - !clientAcceptsAgentStreamProtocol(req.headers) - ) { + const hasAgentStreamOptions = hasAgentStreamPolicy({ + includeThinking: body.includeThinking, + includeToolCalls: body.includeToolCalls, + }) + if (hasAgentStreamOptions && !body.stream) { + return v2Error('BAD_REQUEST', 'includeThinking and includeToolCalls require stream: true') + } + if (hasAgentStreamOptions && !clientAcceptsAgentStreamProtocol(req.headers)) { return v2Error( 'BAD_REQUEST', `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.` diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 4c09a9fc7ea..15bd3cb2fab 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -12,6 +12,7 @@ import { WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND, } from '@/lib/api/contracts/v2/openapi/shared' import { + EXECUTE_OPTION_CONSTRAINTS, v2CancelWorkflowRunContract, v2CreateWorkflowContract, v2CreateWorkflowFolderContract, @@ -515,8 +516,7 @@ const routes = [ workflowOperation({ operationId: 'executeWorkflowV2', summary: 'Execute Workflow', - 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.', + 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. ${EXECUTE_OPTION_CONSTRAINTS}`, errors: [ 'BadRequest', 'Unauthorized', diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 08c9f7532f6..27258ea444d 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -748,7 +748,7 @@ export type V2ExecutionError = z.output * description cannot drift from each other. */ export const EXECUTE_OPTION_CONSTRAINTS = - '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.' + '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.' /** * Strict public execute body. Async is body-selected (`async: true`) — v2 has @@ -756,7 +756,7 @@ export const EXECUTE_OPTION_CONSTRAINTS = * (triggerType, draft state, deployment pinning) are NEVER wire fields; they * are typed options on the execution service. * - * The five rejected option combinations are enumerated in + * The six rejected option combinations are enumerated in * {@link EXECUTE_OPTION_CONSTRAINTS} and enforced by the route after parsing. */ export const v2ExecuteWorkflowBodySchema = z @@ -806,14 +806,14 @@ export const v2ExecuteWorkflowBodySchema = z .optional() .default(false) .describe( - '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.' + '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.' ), includeToolCalls: z .boolean() .optional() .default(false) .describe( - '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.' + '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.' ), includeFileBase64: z .boolean() From c9a832043cd1ae6302f4d822f8fa4649cdd75bde Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 11 Aug 2026 18:41:26 -0700 Subject: [PATCH 3/3] fix comments --- .../update/latest-mac.yml/route.test.ts | 37 +++++++++++-------- .../desktop/update/latest-mac.yml/route.ts | 28 ++++---------- apps/sim/lib/desktop/update-feed.test.ts | 22 +++++------ apps/sim/lib/desktop/update-feed.ts | 21 +++++------ 4 files changed, 49 insertions(+), 59 deletions(-) diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts index 53e8447b33c..7750053970f 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { setEnv } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MANIFEST_ASSET_NAME } from '@/lib/desktop/update-feed' @@ -40,6 +41,7 @@ describe('desktop update manifest route', () => { beforeEach(() => { fetchMock.mockReset() vi.stubGlobal('fetch', fetchMock) + setEnv({ APPCONFIG_ENVIRONMENT: undefined }) }) afterEach(() => { @@ -47,10 +49,11 @@ describe('desktop update manifest route', () => { }) it.each([ - ['www.dev.sim.ai', 'v1.2.0-dev.4', '1.2.0-dev.4'], - ['www.staging.sim.ai', 'v1.2.0-staging.5', '1.2.0-staging.5'], - ['www.sim.ai', 'v1.1.0', '1.1.0'], - ])('serves the newest release for %s', async (hostname, tag, version) => { + ['dev', 'v1.2.0-dev.4', '1.2.0-dev.4'], + ['staging', 'v1.2.0-staging.5', '1.2.0-staging.5'], + ['production', 'v1.1.0', '1.1.0'], + ])('serves the newest release for the %s deployment', async (environment, tag, version) => { + setEnv({ APPCONFIG_ENVIRONMENT: environment }) fetchMock.mockImplementation(async (input: string | URL | Request) => { const url = String(input) if (url === RELEASES_URL) { @@ -66,7 +69,7 @@ describe('desktop update manifest route', () => { return new Response(null, { status: 404 }) }) - const response = await getFeed(hostname) + const response = await getFeed('internal.service.local') const body = await response.text() expect(response.status).toBe(200) @@ -78,12 +81,13 @@ describe('desktop update manifest route', () => { }) it.each([ - ['dev', 'www.dev.sim.ai:443', 'v1.2.0-dev.4', '1.2.0-dev.4'], - ['staging', 'www.staging.sim.ai:443', 'v1.2.0-staging.5', '1.2.0-staging.5'], - ['production', 'www.sim.ai:443', 'v1.1.0', '1.1.0'], + ['dev', 'www.staging.sim.ai:443', 'v1.2.0-dev.4', '1.2.0-dev.4'], + ['staging', 'www.sim.ai:443', 'v1.2.0-staging.5', '1.2.0-staging.5'], + ['production', 'www.dev.sim.ai:443', 'v1.1.0', '1.1.0'], ])( - 'uses the forwarded public hostname for %s behind a reverse proxy', - async (_, host, tag, version) => { + 'ignores request-controlled host headers for the %s deployment', + async (environment, spoofedHost, tag, version) => { + setEnv({ APPCONFIG_ENVIRONMENT: environment }) fetchMock.mockImplementation(async (input: string | URL | Request) => { const url = String(input) if (url === RELEASES_URL) { @@ -100,7 +104,8 @@ describe('desktop update manifest route', () => { }) const response = await getFeed('internal.service.local', { - 'x-forwarded-host': `${host}, internal.service.local`, + host: spoofedHost, + 'x-forwarded-host': `attacker.example, ${spoofedHost}`, }) const body = await response.text() @@ -110,24 +115,25 @@ describe('desktop update manifest route', () => { } ) - it('falls back to the Host header when no forwarded host is present', async () => { + it('defaults self-hosted deployments to the stable channel', async () => { fetchMock.mockImplementation(async (input: string | URL | Request) => { const url = String(input) if (url === RELEASES_URL) { return Response.json([release('v1.2.0-dev.4'), release('v1.1.0')]) } - if (url === `https://downloads.example/v1.2.0-dev.4/${MANIFEST_ASSET_NAME}`) { - return new Response(manifest('1.2.0-dev.4')) + if (url === `https://downloads.example/v1.1.0/${MANIFEST_ASSET_NAME}`) { + return new Response(manifest('1.1.0')) } return new Response(null, { status: 404 }) }) const response = await getFeed('internal.service.local', { host: 'www.dev.sim.ai:443', + 'x-forwarded-host': 'www.dev.sim.ai:443', }) expect(response.status).toBe(200) - expect(await response.text()).toContain('version: 1.2.0-dev.4') + expect(await response.text()).toContain('version: 1.1.0') }) it('reports an authoritative no-release result for production with only prereleases', async () => { @@ -144,6 +150,7 @@ describe('desktop update manifest route', () => { }) it('rejects a manifest whose version does not match its selected release', async () => { + setEnv({ APPCONFIG_ENVIRONMENT: 'dev' }) fetchMock .mockResolvedValueOnce(Response.json([release('v1.2.0-dev.4')])) .mockResolvedValueOnce(new Response(manifest('1.2.0-staging.5'))) diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts index 4b128e31e82..e3b483a210d 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts @@ -1,8 +1,9 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - channelForHostname, + channelForDeploymentEnvironment, DESKTOP_RELEASE_REPO, type DesktopReleaseCandidate, MANIFEST_ASSET_NAME, @@ -21,19 +22,6 @@ const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' const RELEASES_API_URL = `https://api.github.com/repos/${DESKTOP_RELEASE_REPO}/releases?per_page=30` -/** Resolves the public hostname when Next.js is running behind a reverse proxy. */ -function hostnameForRequest(request: NextRequest): string { - const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim() - const host = forwardedHost || request.headers.get('host') - if (!host) return request.nextUrl.hostname - - try { - return new URL(`http://${host}`).hostname - } catch { - return request.nextUrl.hostname - } -} - /** * The per-environment desktop update feed (see `lib/desktop/update-feed.ts`). * @@ -42,14 +30,14 @@ function hostnameForRequest(request: NextRequest): string { * design: the updater's HTTP client carries no session, and the response * only describes public GitHub release artifacts. */ -export const GET = withRouteHandler(async (request: NextRequest): Promise => { +export const GET = withRouteHandler(async (_request: NextRequest): Promise => { /** - * The same deployment configuration can be promoted across environments, so - * its baked NEXT_PUBLIC_APP_URL is not authoritative for this public feed. - * Reverse proxies replace the request URL's hostname with their internal - * origin, so use the forwarded public host to select the channel. + * Hosted deployments inject APPCONFIG_ENVIRONMENT independently at runtime, + * so it stays correct when the same image is promoted across environments. + * Request host headers are intentionally excluded: this public route must not + * let a caller choose which app-identity release the feed serves. */ - const channel = channelForHostname(hostnameForRequest(request)) + const channel = channelForDeploymentEnvironment(env.APPCONFIG_ENVIRONMENT) // A token raises the GitHub API quota from 60/h per NAT IP to 5000/h. // Optional: the repo is public, so the feed works without one. diff --git a/apps/sim/lib/desktop/update-feed.test.ts b/apps/sim/lib/desktop/update-feed.test.ts index c89023d6997..670886ce6fc 100644 --- a/apps/sim/lib/desktop/update-feed.test.ts +++ b/apps/sim/lib/desktop/update-feed.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { - channelForHostname, + channelForDeploymentEnvironment, channelOfVersion, MANIFEST_ASSET_NAME, rewriteManifestUrls, @@ -22,19 +22,17 @@ function release( } } -describe('channelForHostname', () => { - it('maps hosted environments to their channels', () => { - expect(channelForHostname('dev.sim.ai')).toBe('dev') - expect(channelForHostname('www.dev.sim.ai')).toBe('dev') - expect(channelForHostname('staging.sim.ai')).toBe('staging') - expect(channelForHostname('www.staging.sim.ai')).toBe('staging') - expect(channelForHostname('sim.ai')).toBe('latest') - expect(channelForHostname('www.sim.ai')).toBe('latest') +describe('channelForDeploymentEnvironment', () => { + it('maps hosted deployment environments to their channels', () => { + expect(channelForDeploymentEnvironment('dev')).toBe('dev') + expect(channelForDeploymentEnvironment('staging')).toBe('staging') + expect(channelForDeploymentEnvironment('production')).toBe('latest') }) - it('defaults self-hosted and local deployments to stable', () => { - expect(channelForHostname('sim.example.com')).toBe('latest') - expect(channelForHostname('localhost')).toBe('latest') + it('defaults self-hosted, local, and unknown deployments to stable', () => { + expect(channelForDeploymentEnvironment(undefined)).toBe('latest') + expect(channelForDeploymentEnvironment('')).toBe('latest') + expect(channelForDeploymentEnvironment('unknown')).toBe('latest') }) }) diff --git a/apps/sim/lib/desktop/update-feed.ts b/apps/sim/lib/desktop/update-feed.ts index 3f6e7e959f5..69efd67400c 100644 --- a/apps/sim/lib/desktop/update-feed.ts +++ b/apps/sim/lib/desktop/update-feed.ts @@ -6,9 +6,9 @@ * GitHub feed, so each environment independently controls which shell build * its clients are offered. The environment IS the channel: * - * - dev.sim.ai → `dev` (per-push prerelease builds from `dev`) - * - staging.sim.ai → `staging` (per-push prerelease builds from `staging`) - * - sim.ai + self-hosted/unknown → `latest` (stable vX.Y.Z releases only) + * - hosted `dev` deployment → `dev` (per-push prerelease builds from `dev`) + * - hosted `staging` deployment → `staging` (per-push prerelease builds from `staging`) + * - production + self-hosted → `latest` (stable vX.Y.Z releases only) * * Artifacts stay on GitHub Releases (dumb storage); the feed route picks the * right release for its channel and serves that release's electron-updater @@ -28,15 +28,12 @@ export const DESKTOP_RELEASE_REPO = 'simstudioai/sim' export type DesktopUpdateChannel = 'dev' | 'staging' | 'latest' -/** Maps a deployment hostname to its desktop update channel. */ -export function channelForHostname(hostname: string): DesktopUpdateChannel { - const host = hostname.toLowerCase() - if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) { - return 'dev' - } - if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) { - return 'staging' - } +/** Maps Sim's server-controlled deployment environment to its update channel. */ +export function channelForDeploymentEnvironment( + environment: string | undefined +): DesktopUpdateChannel { + if (environment === 'dev') return 'dev' + if (environment === 'staging') return 'staging' return 'latest' }