mirror of
https://github.com/different-ai/openwork
synced 2026-05-15 03:26:24 +02:00
feat(sandbox): add docker-gated sandbox workspaces
This commit is contained in:
@@ -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"}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "添加工作区",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user