From 044f21b06469028c09aa0fb857589e2c60d4c19a Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Mon, 9 Feb 2026 19:50:41 -0800 Subject: [PATCH] feat(sandbox): add docker-gated sandbox workspaces --- packages/app/src/app/app.tsx | 40 ++- .../app/components/create-workspace-modal.tsx | 89 ++++-- .../src/app/components/session/composer.tsx | 56 +++- .../src/app/components/session/sidebar.tsx | 16 +- packages/app/src/app/context/workspace.ts | 126 +++++++- packages/app/src/app/lib/openwork-server.ts | 124 ++++++++ packages/app/src/app/lib/tauri.ts | 50 +++- packages/app/src/app/pages/dashboard.tsx | 19 +- packages/app/src/app/pages/session.tsx | 32 +- packages/app/src/i18n/locales/en.ts | 5 + packages/app/src/i18n/locales/zh.ts | 5 + .../desktop/src-tauri/src/commands/openwrk.rs | 281 ++++++++++++++++-- .../src-tauri/src/commands/workspace.rs | 42 +++ packages/desktop/src-tauri/src/lib.rs | 3 + packages/desktop/src-tauri/src/types.rs | 10 +- .../desktop/src-tauri/src/workspace/state.rs | 3 + 16 files changed, 833 insertions(+), 68 deletions(-) diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 354abde1b..552c3d6c8 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -4328,6 +4328,7 @@ export default function App() { openRenameWorkspace, editWorkspaceConnection: openWorkspaceConnectionSettings, forgetWorkspace: workspaceStore.forgetWorkspace, + stopSandbox: workspaceStore.stopSandbox, scheduledJobs: scheduledJobs(), scheduledJobsSource: scheduledJobsSource(), scheduledJobsSourceReady: scheduledJobsSourceReady(), @@ -4488,8 +4489,10 @@ export default function App() { exportWorkspaceBusy: workspaceStore.exportingWorkspaceConfig(), clientConnected: Boolean(client()), openworkServerStatus: openworkServerStatus(), + openworkServerClient: openworkServerClient(), openworkServerSettings: openworkServerSettings(), openworkServerHostInfo: openworkServerHostInfo(), + openworkServerWorkspaceId: openworkServerWorkspaceId(), engineInfo: workspaceStore.engine(), stopHost, headerStatus: headerStatus(), @@ -4775,10 +4778,41 @@ export default function App() { onConfirm={(preset, folder) => workspaceStore.createWorkspaceFlow(preset, folder) } - onConfirmWorker={(preset, folder) => - workspaceStore.createWorkerFlow(preset, folder) + onConfirmWorker={ + isTauriRuntime() + ? (preset, folder) => workspaceStore.createSandboxFlow(preset, folder) + : undefined } - workerLabel="Create worker" + workerDisabled={(() => { + if (!isTauriRuntime()) return true; + const doctor = workspaceStore.sandboxDoctorResult?.(); + return !doctor?.ready; + })()} + workerDisabledReason={(() => { + if (!isTauriRuntime()) return t("app.error.tauri_required", currentLocale()); + const doctor = workspaceStore.sandboxDoctorResult?.(); + if (doctor?.ready) return null; + if (workspaceStore.sandboxDoctorBusy?.()) { + return t("dashboard.sandbox_checking_docker", currentLocale()); + } + const message = doctor?.error?.trim(); + return message || t("dashboard.sandbox_get_ready_desc", currentLocale()); + })()} + workerCtaLabel={t("dashboard.sandbox_get_ready_action", currentLocale())} + workerCtaDescription={t("dashboard.sandbox_get_ready_desc", currentLocale())} + onWorkerCta={async () => { + const url = "https://www.docker.com/products/docker-desktop/"; + if (isTauriRuntime()) { + const { openUrl } = await import("@tauri-apps/plugin-opener"); + await openUrl(url); + } else { + window.open(url, "_blank", "noopener,noreferrer"); + } + }} + workerRetryLabel={t("common.retry", currentLocale())} + onWorkerRetry={() => { + void workspaceStore.refreshSandboxDoctor?.(); + }} submitting={busy() && busyLabel() === "status.creating_workspace"} /> diff --git a/packages/app/src/app/components/create-workspace-modal.tsx b/packages/app/src/app/components/create-workspace-modal.tsx index 75866f005..14e783df0 100644 --- a/packages/app/src/app/components/create-workspace-modal.tsx +++ b/packages/app/src/app/components/create-workspace-modal.tsx @@ -18,6 +18,13 @@ export default function CreateWorkspaceModal(props: { subtitle?: string; confirmLabel?: string; workerLabel?: string; + workerDisabled?: boolean; + workerDisabledReason?: string | null; + workerCtaLabel?: string; + workerCtaDescription?: string; + onWorkerCta?: () => void; + workerRetryLabel?: string; + onWorkerRetry?: () => void; }) { let pickFolderRef: HTMLButtonElement | undefined; const translate = (key: string) => t(key, currentLocale()); @@ -76,10 +83,14 @@ export default function CreateWorkspaceModal(props: { const title = () => props.title ?? translate("dashboard.create_workspace_title"); const subtitle = () => props.subtitle ?? translate("dashboard.create_workspace_subtitle"); const confirmLabel = () => props.confirmLabel ?? translate("dashboard.create_workspace_confirm"); - const workerLabel = () => props.workerLabel ?? "Create worker"; + const workerLabel = () => props.workerLabel ?? translate("dashboard.create_sandbox_confirm"); const isInline = () => props.inline ?? false; const submitting = () => props.submitting ?? false; + const workerDisabled = () => Boolean(props.workerDisabled); + const workerDisabledReason = () => (props.workerDisabledReason ?? "").trim(); + const showWorkerCallout = () => Boolean(props.onConfirmWorker && workerDisabled() && workerDisabledReason()); + const content = (
@@ -180,37 +191,63 @@ export default function CreateWorkspaceModal(props: {
-
- - +
+ +
+
{translate("dashboard.sandbox_get_ready_title")}
+ +
+ {props.workerCtaDescription?.trim() || workerDisabledReason()} +
+
+
+ + + + + + +
+
- + +
+ + + + + + - - +
); diff --git a/packages/app/src/app/components/session/composer.tsx b/packages/app/src/app/components/session/composer.tsx index db713a322..086616f60 100644 --- a/packages/app/src/app/components/session/composer.tsx +++ b/packages/app/src/app/components/session/composer.tsx @@ -46,6 +46,8 @@ type ComposerProps = { recentFiles: string[]; searchFiles: (query: string) => Promise; isRemoteWorkspace: boolean; + isSandboxWorkspace: boolean; + onUploadInboxFiles?: (files: File[]) => void | Promise; attachmentsEnabled: boolean; attachmentsDisabledReason: string | null; listCommands: () => Promise; @@ -367,6 +369,7 @@ const buildRangeFromOffsets = (root: HTMLElement, start: number, end: number) => export default function Composer(props: ComposerProps) { let editorRef: HTMLDivElement | undefined; let fileInputRef: HTMLInputElement | undefined; + let inboxFileInputRef: HTMLInputElement | undefined; let variantPickerRef: HTMLDivElement | undefined; let mentionSearchRun = 0; let suppressPromptSync = false; @@ -387,6 +390,7 @@ export default function Composer(props: ComposerProps) { const [historyIndex, setHistoryIndex] = createSignal({ prompt: -1, shell: -1 }); const [history, setHistory] = createSignal({ prompt: [] as ComposerDraft[], shell: [] as ComposerDraft[] }); const [variantMenuOpen, setVariantMenuOpen] = createSignal(false); + const [showInboxUploadAction, setShowInboxUploadAction] = createSignal(false); const activeVariant = createMemo(() => props.modelVariant ?? "none"); const attachmentsDisabled = createMemo(() => !props.attachmentsEnabled); @@ -466,7 +470,7 @@ export default function Composer(props: ComposerProps) { })); const all = [...agents, ...recentFiles, ...searchFiles]; const list = query - ? fuzzysort.go(query, all, { keys: ["display"] }).map((entry) => entry.obj) + ? fuzzysort.go(query, all, { keys: ["display"] }).map((entry: any) => entry.obj) : all; const groups: MentionGroup[] = []; const bucket = new Map(); @@ -619,7 +623,7 @@ export default function Composer(props: ComposerProps) { if (!query) return commands.slice(0, 15); return fuzzysort .go(query, commands, { keys: ["name", "description"] }) - .map((entry) => entry.obj) + .map((entry: any) => entry.obj) .slice(0, 15); }); @@ -929,7 +933,6 @@ export default function Composer(props: ComposerProps) { .map((item) => item.getAsFile()) .filter((file): file is File => !!file); const allFiles = files.length ? files : itemFiles; - if (allFiles.length) { event.preventDefault(); const hasSupported = allFiles.some((file) => ACCEPTED_FILE_TYPES.includes(file.type)); @@ -941,6 +944,20 @@ export default function Composer(props: ComposerProps) { return; } + const plainForCheck = clipboard.getData("text/plain") ?? ""; + const trimmedForCheck = plainForCheck.trim(); + if (trimmedForCheck && props.isSandboxWorkspace) { + const hasFileUrl = /file:\/\//i.test(trimmedForCheck); + const hasAbsolutePosix = /(^|\s)\/(Users|home|var|etc|opt|tmp|private|Volumes|Applications)\//.test(trimmedForCheck); + const hasAbsoluteWindows = /(^|\s)[a-zA-Z]:\\/.test(trimmedForCheck); + if (hasFileUrl || hasAbsolutePosix || hasAbsoluteWindows) { + props.onToast( + "Sandboxes can't access local file paths. Upload the file to the workspace inbox instead." + ); + setShowInboxUploadAction(Boolean(props.onUploadInboxFiles)); + } + } + const plain = clipboard.getData("text/plain") || clipboard.getData("text") || ""; const html = clipboard.getData("text/html") || ""; const raw = plain || (html ? htmlToPlainText(html) : ""); @@ -960,6 +977,12 @@ export default function Composer(props: ComposerProps) { emitDraftChange(); }; + createEffect(() => { + if (!props.toast) { + setShowInboxUploadAction(false); + } + }); + const handleDrop = (event: DragEvent) => { if (!event.dataTransfer) return; event.preventDefault(); @@ -1395,7 +1418,18 @@ export default function Composer(props: ComposerProps) {
- {props.toast} +
+ {props.toast} + + + +
@@ -1429,6 +1463,20 @@ export default function Composer(props: ComposerProps) {
+ { + const target = event.currentTarget as HTMLInputElement; + const files = Array.from(target.files ?? []); + if (files.length && props.onUploadInboxFiles) { + void Promise.resolve(props.onUploadInboxFiles(files)); + } + target.value = ""; + }} + /> void; onTestWorkspaceConnection: (workspaceId: string) => void; onForgetWorkspace: (workspaceId: string) => void; + onStopSandbox?: (workspaceId: string) => void; onReorderWorkspace: (fromId: string, toId: string | null) => void; onSelectSession: (workspaceId: string, sessionId: string) => void; selectedSessionId: string | null; @@ -359,7 +360,7 @@ export default function SessionSidebar(props: SidebarProps) { - Remote + {group.workspace.sandboxContainerName?.trim() ? "Sandbox" : "Remote"}
@@ -439,6 +440,17 @@ export default function SessionSidebar(props: SidebarProps) { Test connection + + + + + +