diff --git a/.docker/caddy/Caddyfile b/.docker/caddy/Caddyfile index cf2c5365d..0b119f678 100644 --- a/.docker/caddy/Caddyfile +++ b/.docker/caddy/Caddyfile @@ -50,8 +50,8 @@ handle @local_sandbox { rewrite * {re.local_sandbox.2} - reverse_proxy host.docker.internal:18081 { - header_up Host {re.local_sandbox.1}-sandbox-server.roomotepreview.localhost:18081 + reverse_proxy host.docker.internal:{$ROOMOTE_PREVIEW_PROXY_PORT:18081} { + header_up Host {re.local_sandbox.1}-sandbox-server.{$ROOMOTE_PREVIEW_DOMAIN:roomotepreview.localhost}:{$ROOMOTE_PREVIEW_PROXY_PORT:18081} lb_try_duration 10s lb_try_interval 250ms } diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index d24437bb9..cccad65c9 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -238,6 +238,12 @@ the last upgrade without touching the database; the pre-upgrade bundle plus application and schema versions are visible under Settings -> Deployment -> Diagnostics. +Use `roomote upgrade` instead of updating application image references alone. +The upgrade refreshes the release's Compose and Caddy configuration together; +mixing newer application images with an older Caddyfile can leave routes used +by the new controller unavailable until the deployment configuration is also +updated. + `roomote upgrade` prunes old Roomote images after a successful upgrade. It keeps the current release plus the newest local Roomote image tags for a total of `ROOMOTE_IMAGE_RETENTION_RELEASES` retained tags (default `3`); set that diff --git a/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts b/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts index 3c2ff4aeb..335eb3fe6 100644 --- a/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts +++ b/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts @@ -207,6 +207,17 @@ describe('buildDockerSandboxServerUrl', () => { ).toBe('https://roomote-example.ngrok.app/_roomote-sandbox/task123456789'); }); + it('routes networked Docker sandbox transport through the app origin for local preview domains', () => { + expect( + buildDockerSandboxServerUrl({ + network: 'roomote_default', + taskId: 'task123456789', + publicAppUrl: 'http://192.168.50.78:28080/', + previewProxyBaseUrl: 'http://preview.localhost:28080', + }), + ).toBe('http://192.168.50.78:28080/_roomote-sandbox/task123456789'); + }); + it('keeps the direct published URL fallback without a public app origin', () => { expect( buildDockerSandboxServerUrl({ diff --git a/apps/controller/src/compute-providers/spawn-docker-worker.ts b/apps/controller/src/compute-providers/spawn-docker-worker.ts index 598c16d2e..2aa456005 100644 --- a/apps/controller/src/compute-providers/spawn-docker-worker.ts +++ b/apps/controller/src/compute-providers/spawn-docker-worker.ts @@ -5,6 +5,7 @@ import { TaskPayloadKind, NonRetryableSpawnError, getPrimaryPortFromConfig, + isLocalPreviewDomain, portNameToSlug, SANDBOX_SERVER_NAMED_PORT, TaskRunErrorCode, @@ -804,6 +805,22 @@ export function buildDockerSandboxServerUrl(params: { return undefined; } + const localPreviewDomain = (() => { + try { + return isLocalPreviewDomain( + params.previewProxyBaseUrl + ? new URL(params.previewProxyBaseUrl).hostname + : null, + ); + } catch { + return false; + } + })(); + + if (params.publicAppUrl && (!params.network || localPreviewDomain)) { + return `${params.publicAppUrl.replace(/\/+$/, '')}/_roomote-sandbox/${params.taskId}`; + } + if (params.network && params.previewProxyBaseUrl) { return buildPreviewProxyUrl( params.taskId, @@ -813,10 +830,6 @@ export function buildDockerSandboxServerUrl(params: { ); } - if (!params.network && params.publicAppUrl) { - return `${params.publicAppUrl.replace(/\/+$/, '')}/_roomote-sandbox/${params.taskId}`; - } - return undefined; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.client.test.tsx index 6a4d160bb..f930d38de 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.client.test.tsx @@ -25,6 +25,7 @@ const { removeOptimisticMessageMock, removeOptimisticQueuedMessageMock, sandboxSendPromptMutateMock, + taskRunCancelMutateMock, toastErrorMock, toggleVoiceDictationMock, useMutationMock, @@ -59,6 +60,7 @@ const { removeOptimisticMessageMock: vi.fn(), removeOptimisticQueuedMessageMock: vi.fn(), sandboxSendPromptMutateMock: vi.fn(), + taskRunCancelMutateMock: vi.fn(), toastErrorMock: vi.fn(), toggleVoiceDictationMock: vi.fn(), useMutationMock: vi.fn(), @@ -248,15 +250,24 @@ vi.mock('./ContextUsage', () => ({ vi.mock('./SubmitWithAttachments', () => ({ SubmitWithAttachments: ({ connected, + handleCancel, + isTaskRunning, prompt, }: { connected: boolean; + handleCancel: () => void; + isTaskRunning: boolean; prompt: string; - }) => ( - - ), + }) => + isTaskRunning && !prompt.trim() ? ( + + ) : ( + + ), })); vi.mock('./TaskStatus', () => ({ @@ -320,6 +331,7 @@ describe('PromptInput', () => { }, }); sandboxSendPromptMutateMock.mockResolvedValue({ success: true }); + taskRunCancelMutateMock.mockResolvedValue({ success: true }); preparePromptAttachmentsMock.mockImplementation(async (input) => ({ text: input.text, })); @@ -329,6 +341,11 @@ describe('PromptInput', () => { mutate: sandboxSendPromptMutateMock, }, }, + taskRuns: { + cancel: { + mutate: taskRunCancelMutateMock, + }, + }, }); useVoiceDictationMock.mockReturnValue({ @@ -368,6 +385,144 @@ describe('PromptInput', () => { expect(screen.queryByText('Task status')).not.toBeInTheDocument(); }); + it('cancels through the web API when the sandbox client is disconnected', async () => { + useSandboxTaskPhaseMock.mockReturnValue('running'); + + render( + {}} + onCommandSearchOpen={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + + await waitFor(() => { + expect(taskRunCancelMutateMock).toHaveBeenCalledWith({ + taskId: 'task-disconnected', + runId: 42, + }); + }); + }); + + it('falls back to the web API when sandbox cancellation fails', async () => { + const sandboxCancelMutateMock = vi + .fn() + .mockRejectedValue(new Error('WebSocket disconnected')); + + useSandboxConnectedMock.mockReturnValue(true); + useSandboxConnectionStatusMock.mockReturnValue({ + connected: true, + connectionError: false, + reconnect: vi.fn(), + }); + useSandboxTaskPhaseMock.mockReturnValue('running'); + useSandboxClientMock.mockReturnValue({ + commands: { + cancelTask: { mutate: sandboxCancelMutateMock }, + touchKeepalive: { mutate: vi.fn().mockResolvedValue(undefined) }, + }, + }); + + render( + {}} + onCommandSearchOpen={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + + await waitFor(() => { + expect(sandboxCancelMutateMock).toHaveBeenCalledTimes(1); + expect(taskRunCancelMutateMock).toHaveBeenCalledWith({ + taskId: 'task-fallback', + runId: 43, + }); + }); + }); + + it('falls back to the web API when sandbox cancellation hangs', async () => { + vi.useFakeTimers(); + + try { + const sandboxCancelMutateMock = vi + .fn() + .mockImplementation(() => new Promise(() => {})); + + useSandboxConnectedMock.mockReturnValue(true); + useSandboxConnectionStatusMock.mockReturnValue({ + connected: true, + connectionError: false, + reconnect: vi.fn(), + }); + useSandboxTaskPhaseMock.mockReturnValue('running'); + useSandboxClientMock.mockReturnValue({ + commands: { + cancelTask: { mutate: sandboxCancelMutateMock }, + touchKeepalive: { mutate: vi.fn().mockResolvedValue(undefined) }, + }, + }); + + render( + {}} + onCommandSearchOpen={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + + expect(sandboxCancelMutateMock).toHaveBeenCalledTimes(1); + expect(taskRunCancelMutateMock).toHaveBeenCalledWith({ + taskId: 'task-hung', + runId: 45, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('does not call the web API when sandbox cancellation succeeds', async () => { + const sandboxCancelMutateMock = vi.fn().mockResolvedValue(undefined); + + useSandboxConnectedMock.mockReturnValue(true); + useSandboxConnectionStatusMock.mockReturnValue({ + connected: true, + connectionError: false, + reconnect: vi.fn(), + }); + useSandboxTaskPhaseMock.mockReturnValue('running'); + useSandboxClientMock.mockReturnValue({ + commands: { + cancelTask: { mutate: sandboxCancelMutateMock }, + touchKeepalive: { mutate: vi.fn().mockResolvedValue(undefined) }, + }, + }); + + render( + {}} + onCommandSearchOpen={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + + await waitFor(() => { + expect(sandboxCancelMutateMock).toHaveBeenCalledTimes(1); + }); + expect(taskRunCancelMutateMock).not.toHaveBeenCalled(); + }); + it('hides the connecting status when the transport already failed', () => { render( void; @@ -120,6 +121,7 @@ export const PromptInput = forwardRef( const steeringQueuedMessageRef = useRef(false); const textareaRef = useRef(null); const runId = taskRun?.id; + const taskId = taskRun?.taskId; const draftSaveTimerRef = useRef | null>( null, @@ -357,18 +359,49 @@ export const PromptInput = forwardRef( cancellingRef.current = true; try { - await client?.commands.cancelTask.mutate({ - cancelledBy: { - ...(cancelledByName ? { name: cancelledByName } : {}), - source: 'web', - }, + if (client) { + try { + // A dead transport does not always reject quickly (it can hang + // for minutes on an unresponsive upstream), so bound the live + // cancellation before falling back to the web API. + await Promise.race([ + client.commands.cancelTask.mutate({ + cancelledBy: { + ...(cancelledByName ? { name: cancelledByName } : {}), + source: 'web', + }, + }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('sandbox cancelTask timed out')), + SANDBOX_CANCEL_TIMEOUT_MS, + ), + ), + ]); + return; + } catch (error) { + console.error('[sandbox] cancelTask error:', error); + } + } + + if (!taskId) { + return; + } + + const result = await trpcClient.taskRuns.cancel.mutate({ + taskId, + runId, }); + + if (!result.success) { + throw new Error(result.error); + } } catch (err) { - console.error('[sandbox] cancelTask error:', err); + console.error('[sandbox] cancelTask fallback error:', err); } finally { cancellingRef.current = false; } - }, [client, cancelledByName]); + }, [client, cancelledByName, runId, taskId, trpcClient]); const handleSubmit = useCallback( async (message: PromptInputMessage) => { diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/useOptimisticPromptSubmission.ts b/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/useOptimisticPromptSubmission.ts index f45fb4fea..08e14b054 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/useOptimisticPromptSubmission.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/useOptimisticPromptSubmission.ts @@ -4,6 +4,7 @@ import { useCallback } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import type { TaskMessageEnvelope } from '@/types'; +import { generateClientUuid } from '@/lib/client-uuid'; import { useTRPC } from '@/trpc/client'; import { @@ -68,7 +69,7 @@ export function useOptimisticPromptSubmission() { images, location, }: StartOptimisticPromptSubmissionInput) => { - const clientMessageId = globalThis.crypto.randomUUID(); + const clientMessageId = generateClientUuid(); const optimisticPrompt = createOptimisticPromptArtifacts({ taskId, prompt, diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/TaskToolsButton.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/TaskToolsButton.tsx index ebbade998..ca0df70be 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/TaskToolsButton.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/TaskToolsButton.tsx @@ -4,6 +4,7 @@ import { Fragment, memo } from 'react'; import { toast } from 'sonner'; import { useUser } from '@/hooks/useUser'; +import { generateClientUuid } from '@/lib/client-uuid'; import { useTRPCClient } from '@/trpc/client'; import { @@ -59,7 +60,7 @@ function TaskToolsButtonBase({ {separator && } { - const clientMessageId = globalThis.crypto.randomUUID(); + const clientMessageId = generateClientUuid(); try { await trpcClient.sandboxSession.sendPrompt.mutate({ diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TerminalSidePanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TerminalSidePanel.tsx index 5856ba6d9..9389319df 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TerminalSidePanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TerminalSidePanel.tsx @@ -10,6 +10,7 @@ import { } from 'react'; import * as TabsPrimitive from '@radix-ui/react-tabs'; +import { generateClientUuid } from '@/lib/client-uuid'; import { cn } from '@/lib/utils'; import { BasicTooltip, @@ -110,7 +111,7 @@ export function TerminalSidePanel({ active, onClose }: TerminalSidePanelProps) { const openTerminalTab = useCallback( (options?: { initialCommand?: string }) => { - const id = crypto.randomUUID(); + const id = generateClientUuid(); const sessionId = id; setExtraTerminalTabs((prev) => [ diff --git a/apps/web/src/components/ai-elements/prompt-input.tsx b/apps/web/src/components/ai-elements/prompt-input.tsx index 0f17b37cf..c82f13869 100644 --- a/apps/web/src/components/ai-elements/prompt-input.tsx +++ b/apps/web/src/components/ai-elements/prompt-input.tsx @@ -32,6 +32,7 @@ import { XIcon, } from '@/components/system'; +import { generateClientUuid } from '@/lib/client-uuid'; import { cn } from '@/lib/utils'; import { @@ -389,7 +390,7 @@ export const PromptInput = ({ for (const file of capped) { next.push({ - id: crypto.randomUUID(), + id: generateClientUuid(), type: 'file', url: URL.createObjectURL(file), mediaType: file.type, @@ -646,7 +647,7 @@ export const PromptInput = ({ add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => { const array = Array.isArray(incoming) ? incoming : [incoming]; setReferencedSources((prev) => - prev.concat(array.map((s) => ({ ...s, id: crypto.randomUUID() }))), + prev.concat(array.map((s) => ({ ...s, id: generateClientUuid() }))), ); }, remove: (id: string) => { diff --git a/apps/web/src/lib/__tests__/client-uuid.test.ts b/apps/web/src/lib/__tests__/client-uuid.test.ts new file mode 100644 index 000000000..97976026c --- /dev/null +++ b/apps/web/src/lib/__tests__/client-uuid.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { generateClientUuid } from '../client-uuid'; + +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('generateClientUuid', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('uses crypto.randomUUID when available', () => { + expect(generateClientUuid()).toMatch(UUID_V4_PATTERN); + }); + + it('falls back to a v4 UUID when randomUUID is missing (insecure origins)', () => { + const getRandomValues = globalThis.crypto.getRandomValues.bind( + globalThis.crypto, + ); + + vi.stubGlobal('crypto', { getRandomValues }); + + expect(generateClientUuid()).toMatch(UUID_V4_PATTERN); + }); + + it('generates unique values without randomUUID', () => { + const getRandomValues = globalThis.crypto.getRandomValues.bind( + globalThis.crypto, + ); + + vi.stubGlobal('crypto', { getRandomValues }); + + const seen = new Set( + Array.from({ length: 100 }, () => generateClientUuid()), + ); + + expect(seen.size).toBe(100); + }); +}); diff --git a/apps/web/src/lib/client-uuid.ts b/apps/web/src/lib/client-uuid.ts new file mode 100644 index 000000000..1f934341c --- /dev/null +++ b/apps/web/src/lib/client-uuid.ts @@ -0,0 +1,32 @@ +/** + * UUID generation that works outside secure contexts. + * + * `crypto.randomUUID` is only exposed on https and localhost origins, so + * client code that runs on plain-http LAN deployments (e.g. a self-hosted + * instance reached at http://192.168.x.x) must not call it directly. + */ +export function generateClientUuid(): string { + const cryptoObj = globalThis.crypto; + + if (typeof cryptoObj?.randomUUID === 'function') { + return cryptoObj.randomUUID(); + } + + const bytes = new Uint8Array(16); + + if (typeof cryptoObj?.getRandomValues === 'function') { + cryptoObj.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + + // RFC 4122 version 4 layout. + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')); + + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; +} diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index db5765b06..ee8cb874c 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -33,6 +33,17 @@ } } + @local_sandbox path_regexp local_sandbox ^/_roomote-sandbox/([a-z0-9]+)(/.*)$ + + handle @local_sandbox { + rewrite * {re.local_sandbox.2} + reverse_proxy preview-proxy:8081 { + header_up Host {re.local_sandbox.1}-sandbox-server.{$ROOMOTE_PREVIEW_DOMAIN} + lb_try_duration 10s + lb_try_interval 250ms + } + } + @api { path /_roomote-api /_roomote-api/* } diff --git a/deploy/ci/validate-deployment-artifacts.mjs b/deploy/ci/validate-deployment-artifacts.mjs index 9f11043c1..2c5c284ac 100644 --- a/deploy/ci/validate-deployment-artifacts.mjs +++ b/deploy/ci/validate-deployment-artifacts.mjs @@ -280,6 +280,18 @@ assert( 'caddy: Caddyfile must allow internal mode to remove wildcard on-demand TLS', ); const caddyfile = read('deploy/caddy/Caddyfile'); +assert( + caddyfile.includes( + 'path_regexp local_sandbox ^/_roomote-sandbox/([a-z0-9]+)(/.*)$', + ), + 'caddy: app domain must route same-origin sandbox-server requests', +); +assert( + caddyfile.includes( + 'header_up Host {re.local_sandbox.1}-sandbox-server.{$ROOMOTE_PREVIEW_DOMAIN}', + ), + 'caddy: same-origin sandbox route must target the sandbox-server preview host', +); const renderCaddyTlsMode = (values) => caddyfile .replace('{$ROOMOTE_CADDY_LOCAL_CERTS:}', values.localCertificates) diff --git a/docker-compose.yml b/docker-compose.yml index a31d8235d..66c00a3a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,6 +73,8 @@ services: image: caddy:2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d environment: S3_BUCKET_ARTIFACTS: ${S3_BUCKET_ARTIFACTS:-roomote-artifacts} + ROOMOTE_PREVIEW_PROXY_PORT: ${ROOMOTE_PREVIEW_PROXY_PORT:-18081} + ROOMOTE_PREVIEW_DOMAIN: ${ROOMOTE_PREVIEW_DOMAIN:-roomotepreview.localhost} ports: - '127.0.0.1:18080:18080' volumes: