feat(sandbox): add docker-gated sandbox workspaces

This commit is contained in:
Benjamin Shafii
2026-02-09 19:50:41 -08:00
parent 5fd1a0dc63
commit 044f21b064
16 changed files with 833 additions and 68 deletions

View File

@@ -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"}
/>

View File

@@ -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 = (
<div class="bg-gray-2 border border-gray-6 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div class="p-6 border-b border-gray-6 flex justify-between items-center bg-gray-1">
@@ -180,37 +191,63 @@ export default function CreateWorkspaceModal(props: {
</div>
</div>
<div class="p-6 border-t border-gray-6 bg-gray-1 flex justify-end gap-3">
<Show when={showClose()}>
<Button variant="ghost" onClick={props.onClose} disabled={submitting()}>
{translate("common.cancel")}
</Button>
<div class="p-6 border-t border-gray-6 bg-gray-1 flex flex-col gap-3">
<Show when={showWorkerCallout()}>
<div class="rounded-xl border border-amber-7/30 bg-amber-2/40 px-4 py-3 text-xs text-amber-11">
<div class="font-semibold text-amber-12">{translate("dashboard.sandbox_get_ready_title")}</div>
<Show when={props.workerCtaDescription?.trim() || workerDisabledReason()}>
<div class="mt-1 text-amber-11 leading-relaxed">
{props.workerCtaDescription?.trim() || workerDisabledReason()}
</div>
</Show>
<div class="mt-3 flex flex-wrap items-center gap-2">
<Show when={props.onWorkerCta && props.workerCtaLabel?.trim()}>
<Button variant="outline" onClick={props.onWorkerCta} disabled={submitting()}>
{props.workerCtaLabel}
</Button>
</Show>
<Show when={props.onWorkerRetry && props.workerRetryLabel?.trim()}>
<Button variant="ghost" onClick={props.onWorkerRetry} disabled={submitting()}>
{props.workerRetryLabel}
</Button>
</Show>
</div>
</div>
</Show>
<Show when={props.onConfirmWorker}>
<div class="flex justify-end gap-3">
<Show when={showClose()}>
<Button variant="ghost" onClick={props.onClose} disabled={submitting()}>
{translate("common.cancel")}
</Button>
</Show>
<Show when={props.onConfirmWorker}>
<Button
variant="outline"
onClick={() => props.onConfirmWorker?.(preset(), selectedFolder())}
disabled={!selectedFolder() || submitting() || workerDisabled()}
title={(() => {
if (!selectedFolder()) return translate("dashboard.choose_folder_continue");
if (workerDisabled() && workerDisabledReason()) return workerDisabledReason();
return undefined;
})()}
>
{workerLabel()}
</Button>
</Show>
<Button
variant="outline"
onClick={() => props.onConfirmWorker?.(preset(), selectedFolder())}
onClick={() => props.onConfirm(preset(), selectedFolder())}
disabled={!selectedFolder() || submitting()}
title={!selectedFolder() ? translate("dashboard.choose_folder_continue") : undefined}
>
{workerLabel()}
<Show when={submitting()} fallback={confirmLabel()}>
<span class="inline-flex items-center gap-2">
<Loader2 size={16} class="animate-spin" />
Creating...
</span>
</Show>
</Button>
</Show>
<Button
onClick={() => props.onConfirm(preset(), selectedFolder())}
disabled={!selectedFolder() || submitting()}
title={!selectedFolder() ? translate("dashboard.choose_folder_continue") : undefined}
>
<Show
when={submitting()}
fallback={confirmLabel()}
>
<span class="inline-flex items-center gap-2">
<Loader2 size={16} class="animate-spin" />
Creating...
</span>
</Show>
</Button>
</div>
</div>
</div>
);

View File

@@ -46,6 +46,8 @@ type ComposerProps = {
recentFiles: string[];
searchFiles: (query: string) => Promise<string[]>;
isRemoteWorkspace: boolean;
isSandboxWorkspace: boolean;
onUploadInboxFiles?: (files: File[]) => void | Promise<void>;
attachmentsEnabled: boolean;
attachmentsDisabledReason: string | null;
listCommands: () => Promise<SlashCommandOption[]>;
@@ -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<MentionGroup["category"], MentionOption[]>();
@@ -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) {
<div class="relative min-h-[120px]">
<Show when={props.toast}>
<div class="absolute bottom-full right-0 mb-2 z-30 rounded-xl border border-dls-border bg-dls-surface px-3 py-2 text-xs text-dls-secondary shadow-lg backdrop-blur-md">
{props.toast}
<div class="flex items-center gap-3">
<span>{props.toast}</span>
<Show when={showInboxUploadAction() && props.onUploadInboxFiles}>
<button
type="button"
class="shrink-0 rounded-md border border-dls-border bg-dls-hover px-2 py-1 text-[10px] text-dls-text hover:bg-dls-active"
onClick={() => inboxFileInputRef?.click()}
>
Upload to inbox
</button>
</Show>
</div>
</div>
</Show>
@@ -1429,6 +1463,20 @@ export default function Composer(props: ComposerProps) {
<div class="mt-3 flex items-center justify-between px-2 pb-2">
<div class="flex items-center gap-2">
<input
ref={inboxFileInputRef}
type="file"
multiple
class="hidden"
onChange={(event: Event) => {
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 = "";
}}
/>
<input
ref={fileInputRef}
type="file"

View File

@@ -1,5 +1,5 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { Check, ChevronDown, GripVertical, Loader2, Plus, RefreshCcw, Settings, Trash2 } from "lucide-solid";
import { Check, ChevronDown, GripVertical, Loader2, Plus, RefreshCcw, Settings, Square, Trash2 } from "lucide-solid";
import type { TodoItem, WorkspaceConnectionState } from "../../types";
import type { WorkspaceInfo } from "../../lib/tauri";
@@ -41,6 +41,7 @@ export type SidebarProps = {
onEditWorkspace: (workspaceId: string) => 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) {
</span>
<Show when={group.workspace.workspaceType === "remote"}>
<span class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-3 text-gray-11">
Remote
{group.workspace.sandboxContainerName?.trim() ? "Sandbox" : "Remote"}
</span>
</Show>
</div>
@@ -439,6 +440,17 @@ export default function SessionSidebar(props: SidebarProps) {
Test connection
</button>
</Show>
<Show when={group.workspace.sandboxContainerName?.trim() && props.onStopSandbox}>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-gray-6 px-2 py-1 text-[10px] text-gray-10 hover:text-gray-12 hover:border-gray-7 hover:bg-gray-2 transition-colors"
onClick={() => props.onStopSandbox?.(group.workspace.id)}
disabled={isConnecting()}
>
<Square size={12} />
Stop sandbox
</button>
</Show>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-gray-6 px-2 py-1 text-[10px] text-gray-10 hover:text-gray-12 hover:border-gray-7 hover:bg-gray-2 transition-colors"

View File

@@ -36,6 +36,8 @@ import {
engineInstall,
engineStart,
engineStop,
sandboxDoctor,
sandboxStop,
openwrkInstanceDispose,
openwrkStartDetached,
openwrkWorkspaceActivate,
@@ -55,6 +57,7 @@ import {
workspaceUpdateRemote,
type EngineDoctorResult,
type EngineInfo,
type SandboxDoctorResult,
type WorkspaceInfo,
} from "../lib/tauri";
import { waitForHealthy, createClient, type OpencodeAuth } from "../lib/opencode";
@@ -133,6 +136,9 @@ export function createWorkspaceStore(options: {
const [engineDoctorResult, setEngineDoctorResult] = createSignal<EngineDoctorResult | null>(null);
const [engineDoctorCheckedAt, setEngineDoctorCheckedAt] = createSignal<number | null>(null);
const [engineInstallLogs, setEngineInstallLogs] = createSignal<string | null>(null);
const [sandboxDoctorResult, setSandboxDoctorResult] = createSignal<SandboxDoctorResult | null>(null);
const [sandboxDoctorCheckedAt, setSandboxDoctorCheckedAt] = createSignal<number | null>(null);
const [sandboxDoctorBusy, setSandboxDoctorBusy] = createSignal(false);
let lastEngineReconnectAt = 0;
let reconnectingEngine = false;
@@ -517,6 +523,41 @@ export function createWorkspaceStore(options: {
}
}
async function refreshSandboxDoctor() {
if (!isTauriRuntime()) {
setSandboxDoctorResult(null);
setSandboxDoctorCheckedAt(Date.now());
return null;
}
if (sandboxDoctorBusy()) return sandboxDoctorResult();
setSandboxDoctorBusy(true);
try {
const result = await sandboxDoctor();
setSandboxDoctorResult(result);
return result;
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
const fallback: SandboxDoctorResult = {
installed: false,
daemonRunning: false,
permissionOk: false,
ready: false,
error: message,
};
setSandboxDoctorResult(fallback);
return fallback;
} finally {
setSandboxDoctorCheckedAt(Date.now());
setSandboxDoctorBusy(false);
}
}
createEffect(() => {
if (!createWorkspaceOpen()) return;
if (!isTauriRuntime()) return;
void refreshSandboxDoctor();
});
async function activateWorkspace(workspaceId: string) {
const id = workspaceId.trim();
if (!id) return false;
@@ -1154,7 +1195,7 @@ export function createWorkspaceStore(options: {
}
}
async function createWorkerFlow(preset: WorkspacePreset, folder: string | null) {
async function createSandboxFlow(preset: WorkspacePreset, folder: string | null) {
if (!isTauriRuntime()) {
options.setError(t("app.error.tauri_required", currentLocale()));
return;
@@ -1165,6 +1206,13 @@ export function createWorkspaceStore(options: {
return;
}
const doctor = await refreshSandboxDoctor();
if (!doctor?.ready) {
const detail = doctor?.error?.trim() || "Docker is required for sandboxes. Install Docker Desktop, start it, then retry.";
options.setError(detail);
return;
}
options.setBusy(true);
options.setBusyLabel("status.creating_workspace");
options.setBusyStartedAt(Date.now());
@@ -1177,7 +1225,7 @@ export function createWorkspaceStore(options: {
return;
}
const name = resolvedFolder.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "Worker";
const name = resolvedFolder.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "Workspace";
// Ensure the workspace folder has baseline OpenWork/OpenCode files.
const created = await workspaceCreate({ folderPath: resolvedFolder, name, preset });
@@ -1192,8 +1240,8 @@ export function createWorkspaceStore(options: {
syncActiveWorkspaceId(forgotten.activeId);
}
options.setBusyLabel("Starting host...");
const host = await openwrkStartDetached({ workspacePath: resolvedFolder });
options.setBusyLabel("Starting sandbox...");
const host = await openwrkStartDetached({ workspacePath: resolvedFolder, sandboxBackend: "docker" });
setCreateWorkspaceOpen(false);
options.setTab("scheduled");
@@ -1205,6 +1253,9 @@ export function createWorkspaceStore(options: {
openworkToken: host.token,
directory: resolvedFolder,
displayName: name,
sandboxBackend: host.sandboxBackend ?? "docker",
sandboxRunId: host.sandboxRunId ?? null,
sandboxContainerName: host.sandboxContainerName ?? null,
});
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
@@ -1221,6 +1272,11 @@ export function createWorkspaceStore(options: {
openworkToken?: string | null;
directory?: string | null;
displayName?: string | null;
// Sandbox lifecycle metadata (desktop-managed)
sandboxBackend?: "docker" | null;
sandboxRunId?: string | null;
sandboxContainerName?: string | null;
}) {
const hostUrl = normalizeOpenworkServerUrl(input.openworkHostUrl ?? "") ?? "";
const token = input.openworkToken?.trim() ?? "";
@@ -1312,6 +1368,9 @@ export function createWorkspaceStore(options: {
openworkToken: remoteType === "openwork" ? (token || null) : null,
openworkWorkspaceId: remoteType === "openwork" ? openworkWorkspace?.id ?? null : null,
openworkWorkspaceName: remoteType === "openwork" ? openworkWorkspace?.name ?? null : null,
sandboxBackend: input.sandboxBackend ?? null,
sandboxRunId: input.sandboxRunId ?? null,
sandboxContainerName: input.sandboxContainerName ?? null,
});
setWorkspaces(ws.workspaces);
syncActiveWorkspaceId(ws.activeId);
@@ -1331,6 +1390,9 @@ export function createWorkspaceStore(options: {
openworkToken: remoteType === "openwork" ? (token || null) : null,
openworkWorkspaceId: remoteType === "openwork" ? openworkWorkspace?.id ?? null : null,
openworkWorkspaceName: remoteType === "openwork" ? openworkWorkspace?.name ?? null : null,
sandboxBackend: input.sandboxBackend ?? null,
sandboxRunId: input.sandboxRunId ?? null,
sandboxContainerName: input.sandboxContainerName ?? null,
};
setWorkspaces((prev) => {
@@ -1547,6 +1609,55 @@ export function createWorkspaceStore(options: {
}
}
async function stopSandbox(workspaceId: string) {
if (!isTauriRuntime()) {
options.setError(t("app.error.tauri_required", currentLocale()));
return;
}
const id = workspaceId.trim();
if (!id) return;
const workspace = workspaces().find((entry) => entry.id === id) ?? null;
const containerName = workspace?.sandboxContainerName?.trim() ?? "";
if (!containerName) {
options.setError("Sandbox container name missing.");
return;
}
options.setBusy(true);
options.setBusyLabel("Stopping sandbox...");
options.setBusyStartedAt(Date.now());
options.setError(null);
try {
const result = await sandboxStop(containerName);
if (!result.ok) {
const details = [result.stderr?.trim(), result.stdout?.trim()]
.filter(Boolean)
.join("\n")
.trim();
throw new Error(details || `Failed to stop sandbox (status ${result.status})`);
}
// If the user stopped the active workspace, proactively disconnect the client.
if (activeWorkspaceId() === id) {
options.setClient(null);
options.setConnectedVersion(null);
options.setSseConnected(false);
}
updateWorkspaceConnectionState(id, { status: "error", message: "Sandbox stopped." });
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
options.setError(addOpencodeCacheHint(message));
} finally {
options.setBusy(false);
options.setBusyLabel(null);
options.setBusyStartedAt(null);
}
}
async function pickWorkspaceFolder() {
if (!isTauriRuntime()) {
options.setError(t("app.error.tauri_required", currentLocale()));
@@ -2302,6 +2413,9 @@ export function createWorkspaceStore(options: {
engineDoctorResult,
engineDoctorCheckedAt,
engineInstallLogs,
sandboxDoctorResult,
sandboxDoctorCheckedAt,
sandboxDoctorBusy,
projectDir,
workspaces,
activeWorkspaceId,
@@ -2333,11 +2447,12 @@ export function createWorkspaceStore(options: {
testWorkspaceConnection,
connectToServer,
createWorkspaceFlow,
createWorkerFlow,
createSandboxFlow,
createRemoteWorkspaceFlow,
updateRemoteWorkspaceFlow,
updateWorkspaceDisplayName,
forgetWorkspace,
stopSandbox,
pickWorkspaceFolder,
exportWorkspaceConfig,
importWorkspaceConfig,
@@ -2358,5 +2473,6 @@ export function createWorkspaceStore(options: {
removeAuthorizedDirAtIndex,
persistReloadSettings,
setEngineInstallLogs,
refreshSandboxDoctor,
};
}

View File

@@ -258,6 +258,20 @@ export type OpenworkWorkspaceExport = {
commands?: Array<{ name: string; description?: string; template?: string }>;
};
export type OpenworkArtifactItem = {
id: string;
name?: string;
path?: string;
size?: number;
createdAt?: number;
updatedAt?: number;
mime?: string;
};
export type OpenworkArtifactList = {
items: OpenworkArtifactItem[];
};
type RawJsonResponse<T> = {
ok: boolean;
status: number;
@@ -498,6 +512,20 @@ function buildHeaders(
return headers;
}
function buildAuthHeaders(token?: string, hostToken?: string, extra?: Record<string, string>) {
const headers: Record<string, string> = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (hostToken) {
headers["X-OpenWork-Host-Token"] = hostToken;
}
if (extra) {
Object.assign(headers, extra);
}
return headers;
}
// Use Tauri's fetch when running in the desktop app to avoid CORS issues
const resolveFetch = () => (isTauriRuntime() ? tauriFetch : globalThis.fetch);
@@ -550,6 +578,56 @@ async function requestJsonRaw<T>(
return { ok: response.ok, status: response.status, json };
}
async function requestMultipartRaw(
baseUrl: string,
path: string,
options: { method?: string; token?: string; hostToken?: string; body?: FormData } = {},
): Promise<{ ok: boolean; status: number; text: string }>{
const url = `${baseUrl}${path}`;
const fetchImpl = resolveFetch();
const response = await fetchImpl(url, {
method: options.method ?? "POST",
headers: buildAuthHeaders(options.token, options.hostToken),
body: options.body,
});
const text = await response.text();
return { ok: response.ok, status: response.status, text };
}
async function requestBinary(
baseUrl: string,
path: string,
options: { method?: string; token?: string; hostToken?: string } = {},
): Promise<{ data: ArrayBuffer; contentType: string | null; filename: string | null }>{
const url = `${baseUrl}${path}`;
const fetchImpl = resolveFetch();
const response = await fetchImpl(url, {
method: options.method ?? "GET",
headers: buildAuthHeaders(options.token, options.hostToken),
});
if (!response.ok) {
const text = await response.text();
let json: any = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
const code = typeof json?.code === "string" ? json.code : "request_failed";
const message = typeof json?.message === "string" ? json.message : response.statusText;
throw new OpenworkServerError(response.status, code, message, json?.details);
}
const contentType = response.headers.get("content-type");
const disposition = response.headers.get("content-disposition") ?? "";
const filenameMatch = disposition.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i);
const filenameRaw = filenameMatch?.[1] ?? filenameMatch?.[2] ?? null;
const filename = filenameRaw ? decodeURIComponent(filenameRaw) : null;
const data = await response.arrayBuffer();
return { data, contentType, filename };
}
export function createOpenworkServerClient(options: { baseUrl: string; token?: string; hostToken?: string }) {
const baseUrl = options.baseUrl.replace(/\/+$/, "");
const token = options.token;
@@ -879,6 +957,52 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
method: "DELETE",
},
),
uploadInbox: async (workspaceId: string, file: File, options?: { path?: string }) => {
const id = workspaceId.trim();
if (!id) throw new Error("workspaceId is required");
if (!file) throw new Error("file is required");
const form = new FormData();
form.append("file", file);
if (options?.path?.trim()) {
form.append("path", options.path.trim());
}
const result = await requestMultipartRaw(baseUrl, `/workspace/${encodeURIComponent(id)}/inbox`, {
token,
hostToken,
method: "POST",
body: form,
});
if (!result.ok) {
let message = result.text.trim();
try {
const json = message ? JSON.parse(message) : null;
if (json && typeof json.message === "string") {
message = json.message;
}
} catch {
// ignore
}
throw new OpenworkServerError(result.status, "request_failed", message || "Inbox upload failed");
}
return result.text;
},
listArtifacts: (workspaceId: string) =>
requestJson<OpenworkArtifactList>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/artifacts`, {
token,
hostToken,
}),
downloadArtifact: (workspaceId: string, artifactId: string) =>
requestBinary(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/artifacts/${encodeURIComponent(artifactId)}`,
{ token, hostToken },
),
};
}

View File

@@ -118,6 +118,11 @@ export type WorkspaceInfo = {
openworkToken?: string | null;
openworkWorkspaceId?: string | null;
openworkWorkspaceName?: string | null;
// Sandbox lifecycle metadata (desktop-managed)
sandboxBackend?: "docker" | null;
sandboxRunId?: string | null;
sandboxContainerName?: string | null;
};
export type WorkspaceList = {
@@ -172,6 +177,11 @@ export async function workspaceCreateRemote(input: {
openworkToken?: string | null;
openworkWorkspaceId?: string | null;
openworkWorkspaceName?: string | null;
// Sandbox lifecycle metadata (desktop-managed)
sandboxBackend?: "docker" | null;
sandboxRunId?: string | null;
sandboxContainerName?: string | null;
}): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_create_remote", {
baseUrl: input.baseUrl,
@@ -182,6 +192,9 @@ export async function workspaceCreateRemote(input: {
openworkToken: input.openworkToken ?? null,
openworkWorkspaceId: input.openworkWorkspaceId ?? null,
openworkWorkspaceName: input.openworkWorkspaceName ?? null,
sandboxBackend: input.sandboxBackend ?? null,
sandboxRunId: input.sandboxRunId ?? null,
sandboxContainerName: input.sandboxContainerName ?? null,
});
}
@@ -195,6 +208,11 @@ export async function workspaceUpdateRemote(input: {
openworkToken?: string | null;
openworkWorkspaceId?: string | null;
openworkWorkspaceName?: string | null;
// Sandbox lifecycle metadata (desktop-managed)
sandboxBackend?: "docker" | null;
sandboxRunId?: string | null;
sandboxContainerName?: string | null;
}): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_update_remote", {
workspaceId: input.workspaceId,
@@ -206,6 +224,9 @@ export async function workspaceUpdateRemote(input: {
openworkToken: input.openworkToken ?? null,
openworkWorkspaceId: input.openworkWorkspaceId ?? null,
openworkWorkspaceName: input.openworkWorkspaceName ?? null,
sandboxBackend: input.sandboxBackend ?? null,
sandboxRunId: input.sandboxRunId ?? null,
sandboxContainerName: input.sandboxContainerName ?? null,
});
}
@@ -367,14 +388,41 @@ export type OpenwrkDetachedHost = {
token: string;
hostToken: string;
port: number;
sandboxBackend?: "docker" | null;
sandboxRunId?: string | null;
sandboxContainerName?: string | null;
};
export async function openwrkStartDetached(input: { workspacePath: string }): Promise<OpenwrkDetachedHost> {
export async function openwrkStartDetached(input: {
workspacePath: string;
sandboxBackend?: "none" | "docker" | null;
runId?: string | null;
}): Promise<OpenwrkDetachedHost> {
return invoke<OpenwrkDetachedHost>("openwrk_start_detached", {
workspacePath: input.workspacePath,
sandboxBackend: input.sandboxBackend ?? null,
runId: input.runId ?? null,
});
}
export type SandboxDoctorResult = {
installed: boolean;
daemonRunning: boolean;
permissionOk: boolean;
ready: boolean;
clientVersion?: string | null;
serverVersion?: string | null;
error?: string | null;
};
export async function sandboxDoctor(): Promise<SandboxDoctorResult> {
return invoke<SandboxDoctorResult>("sandbox_doctor");
}
export async function sandboxStop(containerName: string): Promise<ExecResult> {
return invoke<ExecResult>("sandbox_stop", { containerName });
}
export async function openworkServerInfo(): Promise<OpenworkServerInfo> {
return invoke<OpenworkServerInfo>("openwork_server_info");
}

View File

@@ -120,6 +120,7 @@ export type DashboardViewProps = {
openRenameWorkspace: (workspaceId: string) => void;
editWorkspaceConnection: (workspaceId: string) => void;
forgetWorkspace: (workspaceId: string) => void;
stopSandbox: (workspaceId: string) => void;
scheduledJobs: ScheduledJob[];
scheduledJobsSource: "local" | "remote";
scheduledJobsSourceReady: boolean;
@@ -272,7 +273,11 @@ export default function DashboardView(props: DashboardViewProps) {
workspace.path?.trim() ||
"Workspace";
const workspaceKindLabel = (workspace: WorkspaceInfo) =>
workspace.workspaceType === "remote" ? "Remote" : "Local";
workspace.workspaceType === "remote"
? workspace.sandboxContainerName?.trim()
? "Sandbox"
: "Remote"
: "Local";
const openSessionFromList = (workspaceId: string, sessionId: string) => {
// For same-workspace clicks, just select the session without workspace activation
@@ -861,6 +866,18 @@ export default function DashboardView(props: DashboardViewProps) {
Edit connection
</button>
</Show>
<Show when={workspace().sandboxContainerName?.trim()}>
<button
type="button"
class="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-dls-hover"
onClick={() => {
props.stopSandbox(workspace().id);
setWorkspaceMenuId(null);
}}
>
Stop sandbox
</button>
</Show>
<button
type="button"
class="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-dls-hover text-red-11"

View File

@@ -49,7 +49,7 @@ import ProviderAuthModal from "../components/provider-auth-modal";
import ShareWorkspaceModal from "../components/share-workspace-modal";
import StatusBar from "../components/status-bar";
import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient } from "../lib/openwork-server";
import type { OpenworkServerSettings, OpenworkServerStatus } from "../lib/openwork-server";
import type { OpenworkServerClient, OpenworkServerSettings, OpenworkServerStatus } from "../lib/openwork-server";
import { join } from "@tauri-apps/api/path";
import { formatRelativeTime, isTauriRuntime, normalizeDirectoryPath } from "../utils";
@@ -83,8 +83,10 @@ export type SessionViewProps = {
exportWorkspaceBusy: boolean;
clientConnected: boolean;
openworkServerStatus: OpenworkServerStatus;
openworkServerClient: OpenworkServerClient | null;
openworkServerSettings: OpenworkServerSettings;
openworkServerHostInfo: OpenworkServerInfo | null;
openworkServerWorkspaceId: string | null;
engineInfo: EngineInfo | null;
stopHost: () => void;
headerStatus: string;
@@ -991,6 +993,32 @@ export default function SessionView(props: SessionViewProps) {
props.sendPromptAsync(draft).catch(() => undefined);
};
const isSandboxWorkspace = createMemo(() => Boolean((props.activeWorkspaceDisplay as any)?.sandboxContainerName?.trim()));
const uploadInboxFiles = async (files: File[]) => {
const client = props.openworkServerClient;
const workspaceId = props.openworkServerWorkspaceId?.trim() ?? "";
if (!client || !workspaceId) {
setToastMessage("Connect to the OpenWork server to upload inbox files.");
return;
}
if (!files.length) return;
const label = files.length === 1 ? files[0]?.name ?? "file" : `${files.length} files`;
setToastMessage(`Uploading ${label} to inbox...`);
try {
for (const file of files) {
await client.uploadInbox(workspaceId, file);
}
const summary = files.map((file) => file.name).filter(Boolean).join(", ");
setToastMessage(summary ? `Uploaded to inbox: ${summary}` : "Uploaded to inbox.");
} catch (error) {
const message = error instanceof Error ? error.message : "Inbox upload failed";
setToastMessage(message);
}
};
const handleDraftChange = (draft: ComposerDraft) => {
props.setPrompt(draft.text);
};
@@ -1739,6 +1767,8 @@ export default function SessionView(props: SessionViewProps) {
searchFiles={props.searchFiles}
listCommands={props.listCommands}
isRemoteWorkspace={props.activeWorkspaceDisplay.workspaceType === "remote"}
isSandboxWorkspace={isSandboxWorkspace()}
onUploadInboxFiles={isSandboxWorkspace() ? uploadInboxFiles : undefined}
attachmentsEnabled={attachmentsEnabled()}
attachmentsDisabledReason={attachmentsDisabledReason()}
/>

View File

@@ -51,6 +51,11 @@ export default {
"dashboard.create_workspace_title": "Create Workspace",
"dashboard.create_workspace_subtitle": "Initialize a new folder-based workspace.",
"dashboard.create_workspace_confirm": "Create Workspace",
"dashboard.create_sandbox_confirm": "Create as sandbox",
"dashboard.sandbox_get_ready_title": "Sandboxes need Docker",
"dashboard.sandbox_get_ready_action": "Get your system ready",
"dashboard.sandbox_get_ready_desc": "Run this workspace in an isolated Docker container for safer, more reproducible runs.",
"dashboard.sandbox_checking_docker": "Checking Docker...",
"dashboard.create_remote_workspace_title": "Add Remote Workspace",
"dashboard.create_remote_workspace_subtitle": "Save an OpenWork server as a workspace.",
"dashboard.create_remote_workspace_confirm": "Add Workspace",

View File

@@ -51,6 +51,11 @@ export default {
"dashboard.create_workspace_title": "创建工作区",
"dashboard.create_workspace_subtitle": "初始化新的基于文件夹的工作区。",
"dashboard.create_workspace_confirm": "创建工作区",
"dashboard.create_sandbox_confirm": "创建为沙盒",
"dashboard.sandbox_get_ready_title": "沙盒需要 Docker",
"dashboard.sandbox_get_ready_action": "准备沙盒环境",
"dashboard.sandbox_get_ready_desc": "在隔离的 Docker 容器中运行此工作区,更安全、更可复现。",
"dashboard.sandbox_checking_docker": "正在检查 Docker...",
"dashboard.create_remote_workspace_title": "添加远程工作区",
"dashboard.create_remote_workspace_subtitle": "保存 OpenWork 服务器为工作区。",
"dashboard.create_remote_workspace_confirm": "添加工作区",

View File

@@ -2,6 +2,7 @@ use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use std::net::TcpListener;
use std::process::Command;
use std::time::{Duration, Instant};
use tauri::AppHandle;
use tauri::State;
@@ -10,7 +11,7 @@ use uuid::Uuid;
use crate::openwrk::manager::OpenwrkManager;
use crate::openwrk::{resolve_openwrk_data_dir, resolve_openwrk_status};
use crate::types::{OpenwrkStatus, OpenwrkWorkspace};
use crate::types::{ExecResult, OpenwrkStatus, OpenwrkWorkspace};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -19,6 +20,75 @@ pub struct OpenwrkDetachedHost {
pub token: String,
pub host_token: String,
pub port: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_backend: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_container_name: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxDoctorResult {
pub installed: bool,
pub daemon_running: bool,
pub permission_ok: bool,
pub ready: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
fn run_local_command(program: &str, args: &[&str]) -> Result<(i32, String, String), String> {
let output = Command::new(program)
.args(args)
.output()
.map_err(|e| format!("Failed to run {program}: {e}"))?;
let status = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok((status, stdout, stderr))
}
fn parse_docker_client_version(stdout: &str) -> Option<String> {
// Example: "Docker version 26.1.1, build 4cf5afa"
let line = stdout.lines().next().unwrap_or("").trim();
if !line.to_lowercase().starts_with("docker version") {
return None;
}
Some(line.to_string())
}
fn parse_docker_server_version(stdout: &str) -> Option<String> {
// Example line in `docker info` output: " Server Version: 26.1.1"
for line in stdout.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("Server Version:") {
let value = rest.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
fn derive_openwrk_container_name(run_id: &str) -> String {
// Must match openwrk's docker naming scheme:
// `openwrk-${runId.replace(/[^a-zA-Z0-9_.-]+/g, "-").slice(0, 24)}`
let mut sanitized = String::new();
for ch in run_id.chars() {
let ok = ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-';
sanitized.push(if ok { ch } else { '-' });
}
if sanitized.len() > 24 {
sanitized.truncate(24);
}
format!("openwrk-{sanitized}")
}
fn allocate_free_port() -> Result<u16, String> {
@@ -164,12 +234,29 @@ pub fn openwrk_instance_dispose(
pub fn openwrk_start_detached(
app: AppHandle,
workspace_path: String,
sandbox_backend: Option<String>,
run_id: Option<String>,
) -> Result<OpenwrkDetachedHost, String> {
let workspace_path = workspace_path.trim().to_string();
if workspace_path.is_empty() {
return Err("workspacePath is required".to_string());
}
let sandbox_backend = sandbox_backend
.unwrap_or_else(|| "none".to_string())
.trim()
.to_lowercase();
let wants_docker_sandbox = sandbox_backend == "docker";
let sandbox_run_id = run_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| Uuid::new_v4().to_string());
let sandbox_container_name = if wants_docker_sandbox {
Some(derive_openwrk_container_name(&sandbox_run_id))
} else {
None
};
let port = allocate_free_port()?;
let token = Uuid::new_v4().to_string();
let host_token = Uuid::new_v4().to_string();
@@ -182,35 +269,181 @@ pub fn openwrk_start_detached(
// Start a dedicated host stack for this workspace.
// We pass explicit tokens and a free port so the UI can connect deterministically.
command
.args([
"start",
"--workspace",
&workspace_path,
"--approval",
"auto",
"--no-opencode-auth",
"--owpenbot",
"true",
"--detach",
"--openwork-host",
"0.0.0.0",
"--openwork-port",
&port.to_string(),
"--openwork-token",
&token,
"--openwork-host-token",
&host_token,
])
.spawn()
.map_err(|e| format!("Failed to start openwrk: {e}"))?;
{
let mut args: Vec<String> = vec![
"start".to_string(),
"--workspace".to_string(),
workspace_path.clone(),
"--approval".to_string(),
"auto".to_string(),
"--no-opencode-auth".to_string(),
"--owpenbot".to_string(),
"true".to_string(),
"--detach".to_string(),
"--openwork-host".to_string(),
"0.0.0.0".to_string(),
"--openwork-port".to_string(),
port.to_string(),
"--openwork-token".to_string(),
token.clone(),
"--openwork-host-token".to_string(),
host_token.clone(),
"--run-id".to_string(),
sandbox_run_id.clone(),
];
wait_for_openwork_health(&openwork_url, 12_000)?;
if wants_docker_sandbox {
args.push("--sandbox".to_string());
args.push("docker".to_string());
}
// Convert to &str for the shell command builder.
let mut str_args: Vec<&str> = Vec::with_capacity(args.len());
for arg in &args {
str_args.push(arg.as_str());
}
command
.args(str_args)
.spawn()
.map_err(|e| format!("Failed to start openwrk: {e}"))?;
}
let health_timeout_ms = if wants_docker_sandbox { 90_000 } else { 12_000 };
wait_for_openwork_health(&openwork_url, health_timeout_ms)?;
Ok(OpenwrkDetachedHost {
openwork_url,
token,
host_token,
port,
sandbox_backend: if wants_docker_sandbox {
Some("docker".to_string())
} else {
None
},
sandbox_run_id: if wants_docker_sandbox {
Some(sandbox_run_id)
} else {
None
},
sandbox_container_name,
})
}
#[tauri::command]
pub fn sandbox_doctor() -> SandboxDoctorResult {
let (status, stdout, stderr) = match run_local_command("docker", &["--version"]) {
Ok(result) => result,
Err(err) => {
return SandboxDoctorResult {
installed: false,
daemon_running: false,
permission_ok: false,
ready: false,
client_version: None,
server_version: None,
error: Some(err),
};
}
};
if status != 0 {
return SandboxDoctorResult {
installed: false,
daemon_running: false,
permission_ok: false,
ready: false,
client_version: None,
server_version: None,
error: Some(format!(
"docker --version failed (status {status}): {}",
stderr.trim()
)),
};
}
let client_version = parse_docker_client_version(&stdout);
// `docker info` is a good readiness check (installed + daemon reachable + perms).
let (info_status, info_stdout, info_stderr) = match run_local_command("docker", &["info"]) {
Ok(result) => result,
Err(err) => {
return SandboxDoctorResult {
installed: true,
daemon_running: false,
permission_ok: false,
ready: false,
client_version,
server_version: None,
error: Some(err),
};
}
};
if info_status == 0 {
let server_version = parse_docker_server_version(&info_stdout);
return SandboxDoctorResult {
installed: true,
daemon_running: true,
permission_ok: true,
ready: true,
client_version,
server_version,
error: None,
};
}
let combined = format!("{}\n{}", info_stdout.trim(), info_stderr.trim())
.trim()
.to_string();
let lower = combined.to_lowercase();
let permission_ok = !lower.contains("permission denied")
&& !lower.contains("got permission denied")
&& !lower.contains("access is denied");
let daemon_running = !lower.contains("cannot connect to the docker daemon")
&& !lower.contains("is the docker daemon running")
&& !lower.contains("error during connect")
&& !lower.contains("connection refused");
SandboxDoctorResult {
installed: true,
daemon_running,
permission_ok,
ready: false,
client_version,
server_version: None,
error: Some(if combined.is_empty() {
format!("docker info failed (status {info_status})")
} else {
combined
}),
}
}
#[tauri::command]
pub fn sandbox_stop(container_name: String) -> Result<ExecResult, String> {
let name = container_name.trim().to_string();
if name.is_empty() {
return Err("containerName is required".to_string());
}
if !name.starts_with("openwrk-") {
return Err(
"Refusing to stop container: expected name starting with 'openwrk-'".to_string(),
);
}
if !name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-')
{
return Err("containerName contains invalid characters".to_string());
}
let (status, stdout, stderr) = run_local_command("docker", &["stop", &name])?;
Ok(ExecResult {
ok: status == 0,
status,
stdout,
stderr,
})
}

View File

@@ -211,6 +211,9 @@ pub fn workspace_create(
openwork_token: None,
openwork_workspace_id: None,
openwork_workspace_name: None,
sandbox_backend: None,
sandbox_run_id: None,
sandbox_container_name: None,
});
state.active_id = id.clone();
@@ -236,6 +239,9 @@ pub fn workspace_create_remote(
openwork_token: Option<String>,
openwork_workspace_id: Option<String>,
openwork_workspace_name: Option<String>,
sandbox_backend: Option<String>,
sandbox_run_id: Option<String>,
sandbox_container_name: Option<String>,
watch_state: State<WorkspaceWatchState>,
) -> Result<WorkspaceList, String> {
println!("[workspace] create remote request");
@@ -312,6 +318,15 @@ pub fn workspace_create_remote(
openwork_token,
openwork_workspace_id,
openwork_workspace_name,
sandbox_backend: sandbox_backend
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
sandbox_run_id: sandbox_run_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
sandbox_container_name: sandbox_container_name
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
});
state.active_id = id.clone();
save_workspace_state(&app, &state)?;
@@ -337,6 +352,9 @@ pub fn workspace_update_remote(
openwork_token: Option<String>,
openwork_workspace_id: Option<String>,
openwork_workspace_name: Option<String>,
sandbox_backend: Option<String>,
sandbox_run_id: Option<String>,
sandbox_container_name: Option<String>,
) -> Result<WorkspaceList, String> {
println!("[workspace] update remote request: {workspace_id}");
let mut state = load_workspace_state(&app)?;
@@ -417,6 +435,27 @@ pub fn workspace_update_remote(
}
}
if let Some(next_backend) = sandbox_backend
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
entry.sandbox_backend = Some(next_backend);
}
if let Some(next_run_id) = sandbox_run_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
entry.sandbox_run_id = Some(next_run_id);
}
if let Some(next_container) = sandbox_container_name
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
entry.sandbox_container_name = Some(next_container);
}
save_workspace_state(&app, &state)?;
println!("[workspace] update remote complete: {id}");
@@ -870,6 +909,9 @@ pub fn workspace_import_config(
openwork_token: None,
openwork_workspace_id: None,
openwork_workspace_name: None,
sandbox_backend: None,
sandbox_run_id: None,
sandbox_container_name: None,
});
state.active_id = id.clone();
save_workspace_state(&app, &state)?;

View File

@@ -23,6 +23,7 @@ use commands::engine::{engine_doctor, engine_info, engine_install, engine_start,
use commands::misc::{app_build_info, opencode_mcp_auth, reset_opencode_cache, reset_openwork_state};
use commands::openwrk::{
openwrk_instance_dispose, openwrk_start_detached, openwrk_status, openwrk_workspace_activate,
sandbox_doctor, sandbox_stop,
};
use commands::openwork_server::openwork_server_info;
use commands::scheduler::{scheduler_delete_job, scheduler_list_jobs};
@@ -75,6 +76,8 @@ pub fn run() {
openwrk_workspace_activate,
openwrk_instance_dispose,
openwrk_start_detached,
sandbox_doctor,
sandbox_stop,
openwork_server_info,
owpenbot_info,
owpenbot_start,

View File

@@ -326,6 +326,14 @@ pub struct WorkspaceInfo {
pub openwork_workspace_id: Option<String>,
#[serde(default)]
pub openwork_workspace_name: Option<String>,
// Sandbox lifecycle metadata (desktop-managed)
#[serde(default)]
pub sandbox_backend: Option<String>,
#[serde(default)]
pub sandbox_run_id: Option<String>,
#[serde(default)]
pub sandbox_container_name: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
@@ -373,4 +381,4 @@ impl Default for WorkspaceState {
}
}
pub const WORKSPACE_STATE_VERSION: u8 = 3;
pub const WORKSPACE_STATE_VERSION: u8 = 4;

View File

@@ -73,6 +73,9 @@ pub fn ensure_starter_workspace(app: &tauri::AppHandle) -> Result<WorkspaceInfo,
openwork_token: None,
openwork_workspace_id: None,
openwork_workspace_name: None,
sandbox_backend: None,
sandbox_run_id: None,
sandbox_container_name: None,
})
}