Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions apps/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
77 changes: 70 additions & 7 deletions apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -27,8 +28,11 @@ function manifest(version: string) {
return [`version: ${version}`, 'files:', ` - url: Sim-${version}-universal-mac.zip`].join('\n')
}

async function getFeed(hostname: string): Promise<Response> {
return GET(new NextRequest(`https://${hostname}/api/desktop/update/latest-mac.yml`), undefined)
async function getFeed(hostname: string, headers?: HeadersInit): Promise<Response> {
return GET(
new NextRequest(`https://${hostname}/api/desktop/update/latest-mac.yml`, { headers }),
undefined
)
}

describe('desktop update manifest route', () => {
Expand All @@ -37,17 +41,19 @@ describe('desktop update manifest route', () => {
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
setEnv({ APPCONFIG_ENVIRONMENT: undefined })
})

afterEach(() => {
vi.unstubAllGlobals()
})

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) {
Expand All @@ -63,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)
Expand All @@ -74,6 +80,62 @@ describe('desktop update manifest route', () => {
)
})

it.each([
['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'],
])(
'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) {
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', {
host: spoofedHost,
'x-forwarded-host': `attacker.example, ${spoofedHost}`,
})
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('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.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.1.0')
})

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')])
Expand All @@ -88,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')))
Expand Down
17 changes: 10 additions & 7 deletions apps/sim/app/api/desktop/update/latest-mac.yml/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -29,12 +30,14 @@ const RELEASES_API_URL = `https://api.github.com/repos/${DESKTOP_RELEASE_REPO}/r
* 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<Response> => {
// 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)
export const GET = withRouteHandler(async (_request: NextRequest): Promise<Response> => {
/**
* 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 = 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.
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
15 changes: 8 additions & 7 deletions apps/sim/app/api/v2/workflows/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/api/contracts/v2/openapi/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading