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: