mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat: add microsandbox sandbox flow and feature flag toggle (#1446)
* add pre-baked microsandbox image Bake openwork, openwork-server, and the pinned opencode binary into a single Docker image so micro-sandbox remote-connect smoke tests can boot quickly and be verified with curl and container health checks. * add Rust microsandbox example Add a standalone microsandbox SDK example that boots the OpenWork image, validates remote-connect endpoints, and streams sandbox logs so backend-only sandbox behavior can be exercised without Docker. * exclude Rust example build output Keep the standalone microsandbox example in git, but drop generated Cargo target artifacts so the branch only contains source, docs, and lockfile. * test * add microsandbox feature flag for sandbox creation Made-with: Cursor * refactor sandbox mode isolation Made-with: Cursor
This commit is contained in:
17
apps/app/src/app/app-settings/feature-flags-preferences.ts
Normal file
17
apps/app/src/app/app-settings/feature-flags-preferences.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useLocal } from "../context/local";
|
||||
|
||||
export function useFeatureFlagsPreferences() {
|
||||
const { prefs, setPrefs } = useLocal();
|
||||
|
||||
const microsandboxCreateSandboxEnabled = () =>
|
||||
prefs.featureFlags?.microsandboxCreateSandbox === true;
|
||||
|
||||
const toggleMicrosandboxCreateSandbox = () => {
|
||||
setPrefs("featureFlags", "microsandboxCreateSandbox", (current) => !current);
|
||||
};
|
||||
|
||||
return {
|
||||
microsandboxCreateSandboxEnabled,
|
||||
toggleMicrosandboxCreateSandbox,
|
||||
};
|
||||
}
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
import { createProvidersStore } from "./context/providers";
|
||||
import { ModelControlsProvider } from "./app-settings/model-controls-provider";
|
||||
import { createModelControlsStore } from "./app-settings/model-controls-store";
|
||||
import { useFeatureFlagsPreferences } from "./app-settings/feature-flags-preferences";
|
||||
import { useSessionDisplayPreferences } from "./app-settings/session-display-preferences";
|
||||
import {
|
||||
shouldRedirectMissingSessionAfterScopedLoad,
|
||||
@@ -173,6 +174,7 @@ type StartupSessionSnapshot = {
|
||||
|
||||
export default function App() {
|
||||
const { resetSessionDisplayPreferences } = useSessionDisplayPreferences();
|
||||
const { microsandboxCreateSandboxEnabled } = useFeatureFlagsPreferences();
|
||||
const envOpenworkWorkspaceId =
|
||||
typeof import.meta.env?.VITE_OPENWORK_WORKSPACE_ID === "string"
|
||||
? import.meta.env.VITE_OPENWORK_WORKSPACE_ID.trim() || null
|
||||
@@ -861,6 +863,7 @@ export default function App() {
|
||||
developerMode,
|
||||
pendingInitialSessionSelection,
|
||||
setPendingInitialSessionSelection,
|
||||
useMicrosandboxCreateSandbox: microsandboxCreateSandboxEnabled,
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
import { CheckCircle2, Folder, FolderPlus, Globe, Loader2, Sparkles, X } from "lucide-solid";
|
||||
import type { WorkspaceInfo } from "../lib/tauri";
|
||||
import { t, currentLocale } from "../../i18n";
|
||||
import { isSandboxWorkspace } from "../utils";
|
||||
|
||||
import Button from "../components/button";
|
||||
|
||||
@@ -49,12 +50,7 @@ export default function SkillDestinationModal(props: {
|
||||
};
|
||||
|
||||
const workspaceBadge = (workspace: WorkspaceInfo) => {
|
||||
if (
|
||||
workspace.workspaceType === "remote" &&
|
||||
(workspace.sandboxBackend === "docker" ||
|
||||
Boolean(workspace.sandboxRunId?.trim()) ||
|
||||
Boolean(workspace.sandboxContainerName?.trim()))
|
||||
) {
|
||||
if (isSandboxWorkspace(workspace)) {
|
||||
return translate("share_skill_destination.sandbox_badge");
|
||||
}
|
||||
if (workspace.workspaceType === "remote") {
|
||||
@@ -78,12 +74,6 @@ export default function SkillDestinationModal(props: {
|
||||
void props.onSubmitWorkspace(workspaceId);
|
||||
};
|
||||
|
||||
const isSandboxWorkspace = (workspace: WorkspaceInfo) =>
|
||||
workspace.workspaceType === "remote" &&
|
||||
(workspace.sandboxBackend === "docker" ||
|
||||
Boolean(workspace.sandboxRunId?.trim()) ||
|
||||
Boolean(workspace.sandboxContainerName?.trim()));
|
||||
|
||||
const workspaceCircleClass = (workspace: WorkspaceInfo, selected: boolean) => {
|
||||
if (selected) {
|
||||
return "bg-indigo-7/15 text-indigo-11 border border-indigo-7/30";
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
} from "../types";
|
||||
import { normalizeOpenworkServerUrl, parseOpenworkWorkspaceIdFromUrl } from "../lib/openwork-server";
|
||||
import { t } from "../../i18n";
|
||||
import { isTauriRuntime, safeStringify, addOpencodeCacheHint } from "../utils";
|
||||
import { isSandboxWorkspace, isTauriRuntime, safeStringify, addOpencodeCacheHint } from "../utils";
|
||||
import type { WorkspaceStore } from "../context/workspace";
|
||||
import type { StartupPreference } from "../types";
|
||||
import type { OpenworkServerStore } from "../connections/openwork-server-store";
|
||||
@@ -662,9 +662,7 @@ export function createBundlesStore(options: {
|
||||
t("app.worker_fallback");
|
||||
const badge =
|
||||
workspace.workspaceType === "remote"
|
||||
? workspace.sandboxBackend === "docker" ||
|
||||
Boolean(workspace.sandboxRunId?.trim()) ||
|
||||
Boolean(workspace.sandboxContainerName?.trim())
|
||||
? isSandboxWorkspace(workspace)
|
||||
? t("workspace.sandbox_badge")
|
||||
: t("workspace.remote_badge")
|
||||
: t("workspace.local_badge");
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getWorkspaceTaskLoadErrorDisplay,
|
||||
isSandboxWorkspace,
|
||||
isWindowsPlatform,
|
||||
} from "../../utils";
|
||||
import { t } from "../../../i18n";
|
||||
@@ -165,9 +166,7 @@ const workspaceLabel = (workspace: WorkspaceInfo) =>
|
||||
|
||||
const workspaceKindLabel = (workspace: WorkspaceInfo) =>
|
||||
workspace.workspaceType === "remote"
|
||||
? workspace.sandboxBackend === "docker" ||
|
||||
Boolean(workspace.sandboxRunId?.trim()) ||
|
||||
Boolean(workspace.sandboxContainerName?.trim())
|
||||
? isSandboxWorkspace(workspace)
|
||||
? t("workspace.sandbox_badge")
|
||||
: t("workspace.remote_badge")
|
||||
: t("workspace.local_badge");
|
||||
|
||||
@@ -14,6 +14,9 @@ type LocalPreferences = {
|
||||
showThinking: boolean;
|
||||
modelVariant: string | null;
|
||||
defaultModel: ModelRef | null;
|
||||
featureFlags: {
|
||||
microsandboxCreateSandbox: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type LocalContextValue = {
|
||||
@@ -41,6 +44,9 @@ export function LocalProvider(props: ParentProps) {
|
||||
showThinking: false,
|
||||
modelVariant: null,
|
||||
defaultModel: null,
|
||||
featureFlags: {
|
||||
microsandboxCreateSandbox: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
28
apps/app/src/app/context/sandbox-create-mode.ts
Normal file
28
apps/app/src/app/context/sandbox-create-mode.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type SandboxBackendType = "docker" | "microsandbox";
|
||||
|
||||
export type SandboxCreateModeConfig = {
|
||||
backend: SandboxBackendType;
|
||||
sandboxImageRef: string | null;
|
||||
runtimeReadyLabel: string;
|
||||
runtimeCheckingStage: string;
|
||||
};
|
||||
|
||||
export const MICRO_SANDBOX_IMAGE_REF = "openwork-microsandbox:dev";
|
||||
|
||||
export function resolveSandboxCreateMode(useMicrosandbox: boolean): SandboxCreateModeConfig {
|
||||
if (useMicrosandbox) {
|
||||
return {
|
||||
backend: "microsandbox",
|
||||
sandboxImageRef: MICRO_SANDBOX_IMAGE_REF,
|
||||
runtimeReadyLabel: "Microsandbox runtime ready",
|
||||
runtimeCheckingStage: "Checking sandbox runtime...",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backend: "docker",
|
||||
sandboxImageRef: null,
|
||||
runtimeReadyLabel: "Docker ready",
|
||||
runtimeCheckingStage: "Checking Docker...",
|
||||
};
|
||||
}
|
||||
@@ -73,6 +73,7 @@ import { t, currentLocale } from "../../i18n";
|
||||
import { filterProviderList, mapConfigProvidersToList } from "../utils/providers";
|
||||
import { buildDefaultWorkspaceBlueprint, normalizeWorkspaceOpenworkConfig } from "../lib/workspace-blueprints";
|
||||
import type { OpenworkServerStore } from "../connections/openwork-server-store";
|
||||
import { resolveSandboxCreateMode, type SandboxBackendType } from "./sandbox-create-mode";
|
||||
|
||||
export type WorkspaceStore = ReturnType<typeof createWorkspaceStore>;
|
||||
|
||||
@@ -172,6 +173,7 @@ export function createWorkspaceStore(options: {
|
||||
developerMode: () => boolean;
|
||||
pendingInitialSessionSelection?: () => { workspaceId: string; title: string | null; readyAt: number } | null;
|
||||
setPendingInitialSessionSelection?: (input: { workspaceId: string; title: string | null; readyAt: number } | null) => void;
|
||||
useMicrosandboxCreateSandbox?: () => boolean;
|
||||
}) {
|
||||
|
||||
const wsDebugEnabled = () => options.developerMode();
|
||||
@@ -2286,6 +2288,7 @@ export function createWorkspaceStore(options: {
|
||||
|
||||
const runId = makeRunId();
|
||||
const startedAt = Date.now();
|
||||
const sandboxMode = resolveSandboxCreateMode(options.useMicrosandboxCreateSandbox?.() === true);
|
||||
setSandboxCreatePhase("preflight");
|
||||
setSandboxPreflightBusy(true);
|
||||
options.setError(null);
|
||||
@@ -2297,11 +2300,11 @@ export function createWorkspaceStore(options: {
|
||||
setSandboxCreateProgress({
|
||||
runId,
|
||||
startedAt,
|
||||
stage: "Checking Docker...",
|
||||
stage: sandboxMode.runtimeCheckingStage,
|
||||
error: null,
|
||||
logs: [],
|
||||
steps: [
|
||||
{ key: "docker", label: "Docker ready", status: "active", detail: null },
|
||||
{ key: "docker", label: sandboxMode.runtimeReadyLabel, status: "active", detail: null },
|
||||
{ key: "workspace", label: "Prepare worker", status: "pending", detail: null },
|
||||
{ key: "sandbox", label: "Start sandbox services", status: "pending", detail: null },
|
||||
{ key: "health", label: "Wait for OpenWork", status: "pending", detail: null },
|
||||
@@ -2471,7 +2474,8 @@ export function createWorkspaceStore(options: {
|
||||
|
||||
const host = await orchestratorStartDetached({
|
||||
workspacePath: resolvedFolder,
|
||||
sandboxBackend: "docker",
|
||||
sandboxBackend: sandboxMode.backend,
|
||||
sandboxImageRef: sandboxMode.sandboxImageRef,
|
||||
runId,
|
||||
});
|
||||
setSandboxStep("sandbox", { status: "done", detail: host.sandboxContainerName ?? null });
|
||||
@@ -2487,7 +2491,7 @@ export function createWorkspaceStore(options: {
|
||||
openworkHostToken: host.hostToken,
|
||||
directory: resolvedFolder,
|
||||
displayName: name,
|
||||
sandboxBackend: host.sandboxBackend ?? "docker",
|
||||
sandboxBackend: host.sandboxBackend ?? sandboxMode.backend,
|
||||
sandboxRunId: host.sandboxRunId ?? runId,
|
||||
sandboxContainerName: host.sandboxContainerName ?? null,
|
||||
manageBusy: false,
|
||||
@@ -2540,7 +2544,7 @@ export function createWorkspaceStore(options: {
|
||||
closeModal?: boolean;
|
||||
|
||||
// Sandbox lifecycle metadata (desktop-managed)
|
||||
sandboxBackend?: "docker" | null;
|
||||
sandboxBackend?: SandboxBackendType | null;
|
||||
sandboxRunId?: string | null;
|
||||
sandboxContainerName?: string | null;
|
||||
}) {
|
||||
@@ -2611,7 +2615,7 @@ export function createWorkspaceStore(options: {
|
||||
openworkWorkspace = resolved.workspace;
|
||||
resolvedHostUrl = resolved.hostUrl;
|
||||
resolvedAuth = resolved.auth;
|
||||
} else if (input.sandboxBackend === "docker") {
|
||||
} else if (input.sandboxBackend === "docker" || input.sandboxBackend === "microsandbox") {
|
||||
resolvedHostUrl = hostUrl;
|
||||
resolvedBaseUrl = `${hostUrl.replace(/\/+$/, "")}/opencode`;
|
||||
resolvedDirectory = directory || resolvedDirectory;
|
||||
@@ -3011,7 +3015,9 @@ export function createWorkspaceStore(options: {
|
||||
}
|
||||
|
||||
const isSandboxWorkspace =
|
||||
workspace.sandboxBackend === "docker" || Boolean(workspace.sandboxContainerName?.trim());
|
||||
workspace.sandboxBackend === "docker" ||
|
||||
workspace.sandboxBackend === "microsandbox" ||
|
||||
Boolean(workspace.sandboxContainerName?.trim());
|
||||
|
||||
if (!isSandboxWorkspace) {
|
||||
return Boolean(await reconnect());
|
||||
|
||||
@@ -124,7 +124,7 @@ export type WorkspaceInfo = {
|
||||
openworkWorkspaceName?: string | null;
|
||||
|
||||
// Sandbox lifecycle metadata (desktop-managed)
|
||||
sandboxBackend?: "docker" | null;
|
||||
sandboxBackend?: "docker" | "microsandbox" | null;
|
||||
sandboxRunId?: string | null;
|
||||
sandboxContainerName?: string | null;
|
||||
};
|
||||
@@ -210,7 +210,7 @@ export async function workspaceCreateRemote(input: {
|
||||
openworkWorkspaceName?: string | null;
|
||||
|
||||
// Sandbox lifecycle metadata (desktop-managed)
|
||||
sandboxBackend?: "docker" | null;
|
||||
sandboxBackend?: "docker" | "microsandbox" | null;
|
||||
sandboxRunId?: string | null;
|
||||
sandboxContainerName?: string | null;
|
||||
}): Promise<WorkspaceList> {
|
||||
@@ -245,7 +245,7 @@ export async function workspaceUpdateRemote(input: {
|
||||
openworkWorkspaceName?: string | null;
|
||||
|
||||
// Sandbox lifecycle metadata (desktop-managed)
|
||||
sandboxBackend?: "docker" | null;
|
||||
sandboxBackend?: "docker" | "microsandbox" | null;
|
||||
sandboxRunId?: string | null;
|
||||
sandboxContainerName?: string | null;
|
||||
}): Promise<WorkspaceList> {
|
||||
@@ -441,14 +441,15 @@ export type OrchestratorDetachedHost = {
|
||||
ownerToken?: string | null;
|
||||
hostToken: string;
|
||||
port: number;
|
||||
sandboxBackend?: "docker" | null;
|
||||
sandboxBackend?: "docker" | "microsandbox" | null;
|
||||
sandboxRunId?: string | null;
|
||||
sandboxContainerName?: string | null;
|
||||
};
|
||||
|
||||
export async function orchestratorStartDetached(input: {
|
||||
workspacePath: string;
|
||||
sandboxBackend?: "none" | "docker" | null;
|
||||
sandboxBackend?: "none" | "docker" | "microsandbox" | null;
|
||||
sandboxImageRef?: string | null;
|
||||
runId?: string | null;
|
||||
openworkToken?: string | null;
|
||||
openworkHostToken?: string | null;
|
||||
@@ -456,6 +457,7 @@ export async function orchestratorStartDetached(input: {
|
||||
return invoke<OrchestratorDetachedHost>("orchestrator_start_detached", {
|
||||
workspacePath: input.workspacePath,
|
||||
sandboxBackend: input.sandboxBackend ?? null,
|
||||
sandboxImageRef: input.sandboxImageRef ?? null,
|
||||
runId: input.runId ?? null,
|
||||
openworkToken: input.openworkToken ?? null,
|
||||
openworkHostToken: input.openworkHostToken ?? null,
|
||||
|
||||
@@ -23,6 +23,7 @@ import WebUnavailableSurface from "../components/web-unavailable-surface";
|
||||
import DenSettingsPanel from "../components/den-settings-panel";
|
||||
import TextInput from "../components/text-input";
|
||||
import { useModelControls } from "../app-settings/model-controls-provider";
|
||||
import { useFeatureFlagsPreferences } from "../app-settings/feature-flags-preferences";
|
||||
import { useSessionDisplayPreferences } from "../app-settings/session-display-preferences";
|
||||
import { usePlatform } from "../context/platform";
|
||||
import ConfigView from "./config";
|
||||
@@ -226,6 +227,8 @@ const BUG_REPORT_URL =
|
||||
|
||||
export default function SettingsView(props: SettingsViewProps) {
|
||||
const modelControls = useModelControls();
|
||||
const { microsandboxCreateSandboxEnabled, toggleMicrosandboxCreateSandbox } =
|
||||
useFeatureFlagsPreferences();
|
||||
const { showThinking, toggleShowThinking } = useSessionDisplayPreferences();
|
||||
const platform = usePlatform();
|
||||
const webDeployment = createMemo(() => getOpenWorkDeployment() === "web");
|
||||
@@ -2006,6 +2009,33 @@ export default function SettingsView(props: SettingsViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={`${settingsPanelClass} space-y-3`}>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-12">Feature flags</div>
|
||||
<div class="text-xs text-gray-9">
|
||||
Experimental controls for sandbox and workspace behaviors.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between bg-gray-1 p-3 rounded-xl border border-gray-6 gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm text-gray-12">Create Sandbox uses microsandbox image</div>
|
||||
<div class="text-xs text-gray-7">
|
||||
When enabled, Create Sandbox launches the detached worker with the microsandbox
|
||||
image flow instead of the default Docker image flow.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-8 py-0 px-3 shrink-0"
|
||||
onClick={toggleMicrosandboxCreateSandbox}
|
||||
disabled={props.busy || !isTauriRuntime()}
|
||||
>
|
||||
{microsandboxCreateSandboxEnabled() ? "On" : "Off"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={`${settingsPanelClass} space-y-3`}>
|
||||
<div class="text-sm font-medium text-gray-12">{t("settings.developer_mode_title")}</div>
|
||||
<div class="text-xs text-gray-9">
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getWorkspaceTaskLoadErrorDisplay,
|
||||
isSandboxWorkspace,
|
||||
isTauriRuntime,
|
||||
isWindowsPlatform,
|
||||
normalizeDirectoryPath,
|
||||
@@ -296,9 +297,7 @@ export default function SettingsShell(props: SettingsShellProps) {
|
||||
t("share.workspace_fallback");
|
||||
const workspaceKindLabel = (workspace: WorkspaceInfo) =>
|
||||
workspace.workspaceType === "remote"
|
||||
? workspace.sandboxBackend === "docker" ||
|
||||
Boolean(workspace.sandboxRunId?.trim()) ||
|
||||
Boolean(workspace.sandboxContainerName?.trim())
|
||||
? isSandboxWorkspace(workspace)
|
||||
? t("workspace.sandbox_badge")
|
||||
: t("workspace.remote_badge")
|
||||
: t("workspace.local_badge");
|
||||
|
||||
@@ -323,6 +323,7 @@ export function isSandboxWorkspace(workspace: WorkspaceInfo) {
|
||||
return (
|
||||
workspace.workspaceType === "remote" &&
|
||||
(workspace.sandboxBackend === "docker" ||
|
||||
workspace.sandboxBackend === "microsandbox" ||
|
||||
Boolean(workspace.sandboxRunId?.trim()) ||
|
||||
Boolean(workspace.sandboxContainerName?.trim()))
|
||||
);
|
||||
|
||||
@@ -802,6 +802,7 @@ pub fn orchestrator_start_detached(
|
||||
app: AppHandle,
|
||||
workspace_path: String,
|
||||
sandbox_backend: Option<String>,
|
||||
sandbox_image_ref: Option<String>,
|
||||
run_id: Option<String>,
|
||||
openwork_token: Option<String>,
|
||||
openwork_host_token: Option<String>,
|
||||
@@ -816,7 +817,14 @@ pub fn orchestrator_start_detached(
|
||||
.unwrap_or_else(|| "none".to_string())
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let wants_docker_sandbox = sandbox_backend == "docker";
|
||||
if sandbox_backend != "none" && sandbox_backend != "docker" && sandbox_backend != "microsandbox" {
|
||||
return Err("sandboxBackend must be one of: none, docker, microsandbox".to_string());
|
||||
}
|
||||
let wants_docker_sandbox = sandbox_backend == "docker" || sandbox_backend == "microsandbox";
|
||||
let wants_microsandbox = sandbox_backend == "microsandbox";
|
||||
let sandbox_image_ref = sandbox_image_ref
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let sandbox_run_id = run_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -830,7 +838,13 @@ pub fn orchestrator_start_detached(
|
||||
"[sandbox-create][at={start_ts}][runId={}][stage=entry] workspacePath={} sandboxBackend={} container={}",
|
||||
sandbox_run_id,
|
||||
workspace_path,
|
||||
if wants_docker_sandbox { "docker" } else { "none" },
|
||||
if wants_microsandbox {
|
||||
"microsandbox"
|
||||
} else if wants_docker_sandbox {
|
||||
"docker"
|
||||
} else {
|
||||
"none"
|
||||
},
|
||||
sandbox_container_name.as_deref().unwrap_or("<none>")
|
||||
);
|
||||
|
||||
@@ -854,7 +868,14 @@ pub fn orchestrator_start_detached(
|
||||
"workspacePath": workspace_path,
|
||||
"openworkUrl": openwork_url,
|
||||
"port": port,
|
||||
"sandboxBackend": if wants_docker_sandbox { "docker" } else { "none" },
|
||||
"sandboxBackend": if wants_microsandbox {
|
||||
"microsandbox"
|
||||
} else if wants_docker_sandbox {
|
||||
"docker"
|
||||
} else {
|
||||
"none"
|
||||
},
|
||||
"sandboxImageRef": sandbox_image_ref.clone(),
|
||||
"containerName": sandbox_container_name,
|
||||
}),
|
||||
);
|
||||
@@ -906,6 +927,10 @@ pub fn orchestrator_start_detached(
|
||||
if wants_docker_sandbox {
|
||||
args.push("--sandbox".to_string());
|
||||
args.push("docker".to_string());
|
||||
if let Some(image_ref) = sandbox_image_ref.as_deref() {
|
||||
args.push("--sandbox-image".to_string());
|
||||
args.push(image_ref.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to &str for the shell command builder.
|
||||
@@ -1116,7 +1141,11 @@ pub fn orchestrator_start_detached(
|
||||
host_token,
|
||||
port,
|
||||
sandbox_backend: if wants_docker_sandbox {
|
||||
Some("docker".to_string())
|
||||
Some(if wants_microsandbox {
|
||||
"microsandbox".to_string()
|
||||
} else {
|
||||
"docker".to_string()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -1413,6 +1442,7 @@ pub fn sandbox_debug_probe(app: AppHandle) -> SandboxDebugProbeResult {
|
||||
app,
|
||||
workspace_path.clone(),
|
||||
Some("docker".to_string()),
|
||||
None,
|
||||
Some(run_id.clone()),
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -3501,6 +3501,19 @@ async function waitForOpencodeHealthy(
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Some environments have a broken OpenCode /health probe even while the
|
||||
// core API surface is already usable. Accept a successful path lookup as
|
||||
// readiness so session APIs can come up in those runtimes.
|
||||
unwrap(await client.path.get());
|
||||
return { healthy: true, degraded: true, reason: lastError ?? undefined };
|
||||
} catch (error) {
|
||||
if (!lastError) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
}
|
||||
throw new Error(lastError ?? "Timed out waiting for OpenCode health");
|
||||
|
||||
1
examples/microsandbox-openwork-rust/.gitignore
vendored
Normal file
1
examples/microsandbox-openwork-rust/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -0,0 +1,103 @@
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
const redactTarget = (value) => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
if (text.length <= 6) return 'hidden'
|
||||
return `${text.slice(0, 2)}…${text.slice(-2)}`
|
||||
}
|
||||
|
||||
const buildGuidance = (result) => {
|
||||
const sent = Number(result?.sent || 0)
|
||||
const attempted = Number(result?.attempted || 0)
|
||||
const reason = String(result?.reason || '')
|
||||
const failures = Array.isArray(result?.failures) ? result.failures : []
|
||||
|
||||
if (sent > 0 && failures.length === 0) return 'Delivered successfully.'
|
||||
if (sent > 0) return 'Delivered to at least one conversation, but some targets failed.'
|
||||
|
||||
const chatNotFound = failures.some((item) => /chat not found/i.test(String(item?.error || '')))
|
||||
if (chatNotFound) {
|
||||
return 'Delivery failed because the recipient has not started a chat with the bot yet. Ask them to send /start, then retry.'
|
||||
}
|
||||
|
||||
if (/No bound conversations/i.test(reason)) {
|
||||
return 'No linked conversation found for this workspace yet. Ask the recipient to message the bot first, then retry.'
|
||||
}
|
||||
|
||||
if (attempted === 0) return 'No eligible delivery target found.'
|
||||
return 'Delivery failed. Retry after confirming the recipient and bot linkage.'
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: "Send a message via opencodeRouter (Telegram/Slack) to a peer or directory bindings.",
|
||||
args: {
|
||||
text: tool.schema.string().describe("Message text to send"),
|
||||
channel: tool.schema.enum(["telegram", "slack"]).optional().describe("Channel to send on (default: telegram)"),
|
||||
identityId: tool.schema.string().optional().describe("OpenCodeRouter identity id (default: all identities)"),
|
||||
directory: tool.schema.string().optional().describe("Directory to target for fan-out (default: current session directory)"),
|
||||
peerId: tool.schema.string().optional().describe("Direct destination peer id (chat/thread id)"),
|
||||
autoBind: tool.schema.boolean().optional().describe("When direct sending, bind peerId to directory if provided"),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const rawPort = (process.env.OPENCODE_ROUTER_HEALTH_PORT || "3005").trim()
|
||||
const port = Number(rawPort)
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
throw new Error(`Invalid OPENCODE_ROUTER_HEALTH_PORT: ${rawPort}`)
|
||||
}
|
||||
const channel = (args.channel || "telegram").trim()
|
||||
if (channel !== "telegram" && channel !== "slack") {
|
||||
throw new Error("channel must be telegram or slack")
|
||||
}
|
||||
const text = String(args.text || "")
|
||||
if (!text.trim()) throw new Error("text is required")
|
||||
const directory = (args.directory || context.directory || "").trim()
|
||||
const peerId = String(args.peerId || "").trim()
|
||||
if (!directory && !peerId) throw new Error("Either directory or peerId is required")
|
||||
const payload = {
|
||||
channel,
|
||||
text,
|
||||
...(args.identityId ? { identityId: String(args.identityId) } : {}),
|
||||
...(directory ? { directory } : {}),
|
||||
...(peerId ? { peerId } : {}),
|
||||
...(args.autoBind === true ? { autoBind: true } : {}),
|
||||
}
|
||||
const response = await fetch(`http://127.0.0.1:${port}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const body = await response.text()
|
||||
let json = null
|
||||
try {
|
||||
json = JSON.parse(body)
|
||||
} catch {
|
||||
json = null
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`opencodeRouter /send failed (${response.status}): ${body}`)
|
||||
}
|
||||
|
||||
const sent = Number(json?.sent || 0)
|
||||
const attempted = Number(json?.attempted || 0)
|
||||
const reason = typeof json?.reason === 'string' ? json.reason : ''
|
||||
const failuresRaw = Array.isArray(json?.failures) ? json.failures : []
|
||||
const failures = failuresRaw.map((item) => ({
|
||||
identityId: String(item?.identityId || ''),
|
||||
error: String(item?.error || 'delivery failed'),
|
||||
...(item?.peerId ? { target: redactTarget(item.peerId) } : {}),
|
||||
}))
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
channel,
|
||||
sent,
|
||||
attempted,
|
||||
guidance: buildGuidance({ sent, attempted, reason, failures }),
|
||||
...(reason ? { reason } : {}),
|
||||
...(failures.length ? { failures } : {}),
|
||||
}
|
||||
return JSON.stringify(result, null, 2)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
const redactTarget = (value) => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
if (text.length <= 6) return 'hidden'
|
||||
return `${text.slice(0, 2)}…${text.slice(-2)}`
|
||||
}
|
||||
|
||||
const isNumericTelegramPeerId = (value) => /^-?\d+$/.test(String(value || '').trim())
|
||||
|
||||
export default tool({
|
||||
description: "Check opencodeRouter messaging readiness (health, identities, bindings).",
|
||||
args: {
|
||||
channel: tool.schema.enum(["telegram", "slack"]).optional().describe("Channel to inspect (default: telegram)"),
|
||||
identityId: tool.schema.string().optional().describe("Identity id to scope checks"),
|
||||
directory: tool.schema.string().optional().describe("Directory to inspect bindings for (default: current session directory)"),
|
||||
peerId: tool.schema.string().optional().describe("Peer id to inspect bindings for"),
|
||||
includeBindings: tool.schema.boolean().optional().describe("Include binding details (default: false)"),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const rawPort = (process.env.OPENCODE_ROUTER_HEALTH_PORT || "3005").trim()
|
||||
const port = Number(rawPort)
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
throw new Error(`Invalid OPENCODE_ROUTER_HEALTH_PORT: ${rawPort}`)
|
||||
}
|
||||
const channel = (args.channel || "telegram").trim()
|
||||
if (channel !== "telegram" && channel !== "slack") {
|
||||
throw new Error("channel must be telegram or slack")
|
||||
}
|
||||
const identityId = String(args.identityId || "").trim()
|
||||
const directory = (args.directory || context.directory || "").trim()
|
||||
const peerId = String(args.peerId || "").trim()
|
||||
const targetValid = channel !== 'telegram' || !peerId || isNumericTelegramPeerId(peerId)
|
||||
const includeBindings = args.includeBindings === true
|
||||
|
||||
const fetchJson = async (path) => {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`)
|
||||
const body = await response.text()
|
||||
let json = null
|
||||
try {
|
||||
json = JSON.parse(body)
|
||||
} catch {
|
||||
json = null
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { ok: false, status: response.status, json, error: typeof json?.error === "string" ? json.error : body }
|
||||
}
|
||||
return { ok: true, status: response.status, json }
|
||||
}
|
||||
|
||||
const health = await fetchJson('/health')
|
||||
const identities = await fetchJson(`/identities/${channel}`)
|
||||
let bindings = null
|
||||
if (includeBindings) {
|
||||
const search = new URLSearchParams()
|
||||
search.set('channel', channel)
|
||||
if (identityId) search.set('identityId', identityId)
|
||||
bindings = await fetchJson(`/bindings?${search.toString()}`)
|
||||
}
|
||||
|
||||
const identityItems = Array.isArray(identities?.json?.items) ? identities.json.items : []
|
||||
const scopedIdentityItems = identityId
|
||||
? identityItems.filter((item) => String(item?.id || '').trim() === identityId)
|
||||
: identityItems
|
||||
const runningItems = scopedIdentityItems.filter((item) => item && item.enabled === true && item.running === true)
|
||||
const enabledItems = scopedIdentityItems.filter((item) => item && item.enabled === true)
|
||||
|
||||
const bindingItems = Array.isArray(bindings?.json?.items) ? bindings.json.items : []
|
||||
const filteredBindings = bindingItems.filter((item) => {
|
||||
if (!item || typeof item !== 'object') return false
|
||||
if (directory && String(item.directory || '').trim() !== directory) return false
|
||||
if (peerId && String(item.peerId || '').trim() !== peerId) return false
|
||||
return true
|
||||
})
|
||||
const publicBindings = filteredBindings.map((item) => ({
|
||||
channel: String(item.channel || channel),
|
||||
identityId: String(item.identityId || ''),
|
||||
directory: String(item.directory || ''),
|
||||
...(item?.peerId ? { target: redactTarget(item.peerId) } : {}),
|
||||
updatedAt: item?.updatedAt,
|
||||
}))
|
||||
|
||||
let ready = false
|
||||
let guidance = ''
|
||||
let nextAction = ''
|
||||
if (!health.ok) {
|
||||
guidance = 'OpenCode Router health endpoint is unavailable'
|
||||
nextAction = 'check_router_health'
|
||||
} else if (!identities.ok) {
|
||||
guidance = `Identity lookup failed for ${channel}`
|
||||
nextAction = 'check_identity_config'
|
||||
} else if (runningItems.length === 0) {
|
||||
guidance = `No running ${channel} identity`
|
||||
nextAction = 'start_identity'
|
||||
} else if (!targetValid) {
|
||||
guidance = 'Telegram direct targets must be numeric chat IDs. Prefer linked conversations over asking users for raw IDs.'
|
||||
nextAction = 'use_linked_conversation'
|
||||
} else if (peerId) {
|
||||
ready = true
|
||||
guidance = 'Ready for direct send'
|
||||
nextAction = 'send_direct'
|
||||
} else if (directory) {
|
||||
ready = filteredBindings.length > 0
|
||||
guidance = ready
|
||||
? 'Ready for directory fan-out send'
|
||||
: channel === 'telegram'
|
||||
? 'No linked Telegram conversations yet. Ask the recipient to message your bot (for example /start), then retry.'
|
||||
: 'No linked conversations found for this directory yet'
|
||||
nextAction = ready ? 'send_directory' : channel === 'telegram' ? 'wait_for_recipient_start' : 'link_conversation'
|
||||
} else {
|
||||
ready = true
|
||||
guidance = 'Ready. Provide a message target (peer or directory).'
|
||||
nextAction = 'choose_target'
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: health.ok && identities.ok && (!bindings || bindings.ok),
|
||||
ready,
|
||||
guidance,
|
||||
nextAction,
|
||||
channel,
|
||||
...(identityId ? { identityId } : {}),
|
||||
...(directory ? { directory } : {}),
|
||||
...(peerId ? { targetProvided: true } : {}),
|
||||
...(peerId ? { targetValid } : {}),
|
||||
health: {
|
||||
ok: health.ok,
|
||||
status: health.status,
|
||||
error: health.ok ? undefined : health.error,
|
||||
snapshot: health.ok ? health.json : undefined,
|
||||
},
|
||||
identities: {
|
||||
ok: identities.ok,
|
||||
status: identities.status,
|
||||
error: identities.ok ? undefined : identities.error,
|
||||
configured: scopedIdentityItems.length,
|
||||
enabled: enabledItems.length,
|
||||
running: runningItems.length,
|
||||
items: scopedIdentityItems,
|
||||
},
|
||||
...(includeBindings
|
||||
? {
|
||||
bindings: {
|
||||
ok: Boolean(bindings?.ok),
|
||||
status: bindings?.status,
|
||||
error: bindings?.ok ? undefined : bindings?.error,
|
||||
count: filteredBindings.length,
|
||||
items: publicBindings,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
return JSON.stringify(result, null, 2)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}
|
||||
6056
examples/microsandbox-openwork-rust/Cargo.lock
generated
Normal file
6056
examples/microsandbox-openwork-rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
examples/microsandbox-openwork-rust/Cargo.toml
Normal file
12
examples/microsandbox-openwork-rust/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "microsandbox-openwork-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
microsandbox = "0.3.12"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] }
|
||||
69
examples/microsandbox-openwork-rust/README.md
Normal file
69
examples/microsandbox-openwork-rust/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Microsandbox OpenWork Rust Example
|
||||
|
||||
Small standalone Rust example that starts the OpenWork micro-sandbox image with the `microsandbox` SDK, publishes the OpenWork server on a host port, persists `/workspace` and `/data` with host bind mounts, verifies `/health`, checks that `/workspaces` is `401` without a token and `200` with the client token, then keeps the sandbox alive until `Ctrl+C` while streaming the sandbox logs to your terminal.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path examples/microsandbox-openwork-rust/Cargo.toml
|
||||
```
|
||||
|
||||
Useful environment overrides:
|
||||
|
||||
- `OPENWORK_MICROSANDBOX_IMAGE` - OCI image reference to boot. Defaults to `openwork-microsandbox:dev`.
|
||||
- `OPENWORK_MICROSANDBOX_NAME` - sandbox name. Defaults to `openwork-microsandbox-rust`.
|
||||
- `OPENWORK_MICROSANDBOX_WORKSPACE_DIR` - host directory bind-mounted at `/workspace`. Defaults to `examples/microsandbox-openwork-rust/.state/<sandbox-name>/workspace`.
|
||||
- `OPENWORK_MICROSANDBOX_DATA_DIR` - host directory bind-mounted at `/data`. Defaults to `examples/microsandbox-openwork-rust/.state/<sandbox-name>/data`.
|
||||
- `OPENWORK_MICROSANDBOX_REPLACE` - set to `1` or `true` to replace the sandbox instead of reusing persistent state. Defaults to off.
|
||||
- `OPENWORK_MICROSANDBOX_PORT` - published host port. Defaults to `8787`.
|
||||
- `OPENWORK_CONNECT_HOST` - hostname you want clients to use. Defaults to `127.0.0.1`.
|
||||
- `OPENWORK_TOKEN` - remote-connect client token. Defaults to `microsandbox-token`.
|
||||
- `OPENWORK_HOST_TOKEN` - host/admin token. Defaults to `microsandbox-host-token`.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
OPENWORK_MICROSANDBOX_IMAGE=ghcr.io/example/openwork-microsandbox:dev \
|
||||
OPENWORK_MICROSANDBOX_WORKSPACE_DIR="$PWD/examples/microsandbox-openwork-rust/.state/demo/workspace" \
|
||||
OPENWORK_MICROSANDBOX_DATA_DIR="$PWD/examples/microsandbox-openwork-rust/.state/demo/data" \
|
||||
OPENWORK_CONNECT_HOST=127.0.0.1 \
|
||||
OPENWORK_TOKEN=some-shared-secret \
|
||||
OPENWORK_HOST_TOKEN=some-owner-secret \
|
||||
cargo run --manifest-path examples/microsandbox-openwork-rust/Cargo.toml
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
The crate includes an ignored end-to-end smoke test that:
|
||||
|
||||
- boots the microsandbox image
|
||||
- waits for `/health`
|
||||
- verifies unauthenticated `/workspaces` returns `401`
|
||||
- verifies authenticated `/workspaces` returns `200`
|
||||
- creates an OpenCode session through `/w/:workspaceId/opencode/session`
|
||||
- fetches the created session and its messages
|
||||
|
||||
Run it explicitly:
|
||||
|
||||
```bash
|
||||
OPENWORK_MICROSANDBOX_IMAGE=ttl.sh/openwork-microsandbox-11559:1d \
|
||||
cargo test --manifest-path examples/microsandbox-openwork-rust/Cargo.toml -- --ignored --nocapture
|
||||
```
|
||||
|
||||
## Persistence behavior
|
||||
|
||||
By default, the example creates and reuses two host directories under `examples/microsandbox-openwork-rust/.state/<sandbox-name>/`:
|
||||
|
||||
- `/workspace`
|
||||
- `/data`
|
||||
|
||||
That keeps OpenWork and OpenCode state around across sandbox restarts, while using normal host filesystem semantics instead of managed microsandbox named volumes.
|
||||
|
||||
If you want a clean reset, either:
|
||||
|
||||
- change the sandbox name or bind mount paths, or
|
||||
- set `OPENWORK_MICROSANDBOX_REPLACE=1`
|
||||
|
||||
## Note on local Docker images
|
||||
|
||||
`microsandbox` expects an OCI image reference. If `openwork-microsandbox:dev` only exists in your local Docker daemon, the SDK may not be able to resolve it directly. In that case, push the image to a registry or otherwise make it available as a pullable OCI image reference first, then set `OPENWORK_MICROSANDBOX_IMAGE` to that ref.
|
||||
30
examples/microsandbox-openwork-rust/src/bin/debug_env.rs
Normal file
30
examples/microsandbox-openwork-rust/src/bin/debug_env.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use anyhow::Result;
|
||||
use microsandbox::{NetworkPolicy, Sandbox};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let sandbox = Sandbox::builder("owmsb-env-debug")
|
||||
.image("ttl.sh/openwork-microsandbox-11559:1d")
|
||||
.replace()
|
||||
.memory(1024)
|
||||
.cpus(1)
|
||||
.network(|n| n.policy(NetworkPolicy::allow_all()))
|
||||
.create()
|
||||
.await?;
|
||||
|
||||
let out = sandbox
|
||||
.exec(
|
||||
"/bin/sh",
|
||||
[
|
||||
"-lc",
|
||||
"id; pwd; echo HOME=$HOME; echo USER=$USER; echo SHELL=$SHELL; env | sort | grep -E '^(HOME|USER|SHELL|XDG|PATH)=' || true; ls -ld /root /tmp /workspace /data 2>/dev/null || true; /usr/local/bin/opencode --version; rm -f /tmp/opencode.log; (/usr/local/bin/opencode serve --hostname 127.0.0.1 --port 4096 >/tmp/opencode.log 2>&1 &) ; sleep 5; echo '--- HEALTH ---'; curl -iS http://127.0.0.1:4096/health || true; echo; echo '--- SESSION CREATE ---'; curl -iS -X POST -H 'content-type: application/json' -d '{\"title\":\"debug\"}' http://127.0.0.1:4096/session || true; echo; echo '--- PROVIDER ---'; curl -iS http://127.0.0.1:4096/provider || true; echo; echo '--- OPENCODE LOG ---'; cat /tmp/opencode.log || true",
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("stdout:\n{}", out.stdout()?);
|
||||
eprintln!("stderr:\n{}", out.stderr()?);
|
||||
|
||||
sandbox.stop().await?;
|
||||
Ok(())
|
||||
}
|
||||
322
examples/microsandbox-openwork-rust/src/main.rs
Normal file
322
examples/microsandbox-openwork-rust/src/main.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
use anyhow::{Context, Result};
|
||||
use microsandbox::{ExecEvent, NetworkPolicy, Sandbox};
|
||||
use reqwest::StatusCode;
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let image = env::var("OPENWORK_MICROSANDBOX_IMAGE")
|
||||
.unwrap_or_else(|_| "openwork-microsandbox:dev".to_string());
|
||||
let name = env::var("OPENWORK_MICROSANDBOX_NAME")
|
||||
.unwrap_or_else(|_| "openwork-microsandbox-rust".to_string());
|
||||
let workspace_dir = env::var("OPENWORK_MICROSANDBOX_WORKSPACE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| default_bind_dir(&name, "workspace"));
|
||||
let data_dir = env::var("OPENWORK_MICROSANDBOX_DATA_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| default_bind_dir(&name, "data"));
|
||||
let replace = env_flag("OPENWORK_MICROSANDBOX_REPLACE");
|
||||
let host_port = env::var("OPENWORK_MICROSANDBOX_PORT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or(8787);
|
||||
let connect_host =
|
||||
env::var("OPENWORK_CONNECT_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let client_token =
|
||||
env::var("OPENWORK_TOKEN").unwrap_or_else(|_| "microsandbox-token".to_string());
|
||||
let host_token =
|
||||
env::var("OPENWORK_HOST_TOKEN").unwrap_or_else(|_| "microsandbox-host-token".to_string());
|
||||
|
||||
println!(
|
||||
"Starting microsandbox `{name}` from image `{image}` on http://{connect_host}:{host_port}"
|
||||
);
|
||||
|
||||
ensure_bind_dir(&workspace_dir).await?;
|
||||
ensure_bind_dir(&data_dir).await?;
|
||||
|
||||
let mut builder = Sandbox::builder(&name)
|
||||
.image(image.as_str())
|
||||
.memory(2048)
|
||||
.cpus(2)
|
||||
.env("OPENWORK_CONNECT_HOST", &connect_host)
|
||||
.env("OPENWORK_TOKEN", &client_token)
|
||||
.env("OPENWORK_HOST_TOKEN", &host_token)
|
||||
.env("OPENWORK_APPROVAL_MODE", "auto")
|
||||
.port(host_port, 8787)
|
||||
.volume("/workspace", |v| {
|
||||
v.bind(workspace_dir.to_string_lossy().as_ref())
|
||||
})
|
||||
.volume("/data", |v| v.bind(data_dir.to_string_lossy().as_ref()))
|
||||
.network(|n| n.policy(NetworkPolicy::allow_all()));
|
||||
|
||||
if replace {
|
||||
builder = builder.replace();
|
||||
}
|
||||
|
||||
let sandbox = builder
|
||||
.create()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create microsandbox from image `{image}`; if this image only exists in Docker, push it to a registry or otherwise make it available as an OCI image reference first"
|
||||
)
|
||||
})?;
|
||||
|
||||
let server = sandbox
|
||||
.exec_stream(
|
||||
"/bin/sh",
|
||||
["-lc", "/usr/local/bin/microsandbox-entrypoint.sh"],
|
||||
)
|
||||
.await
|
||||
.context("failed to start the OpenWork microsandbox entrypoint inside the VM")?;
|
||||
|
||||
let log_task = tokio::spawn(async move {
|
||||
let mut server = server;
|
||||
while let Some(event) = server.recv().await {
|
||||
match event {
|
||||
ExecEvent::Stdout(data) => print!("{}", String::from_utf8_lossy(&data)),
|
||||
ExecEvent::Stderr(data) => eprint!("{}", String::from_utf8_lossy(&data)),
|
||||
ExecEvent::Exited { code } => {
|
||||
eprintln!("microsandbox entrypoint exited with code {code}");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let base_url = format!("http://127.0.0.1:{host_port}");
|
||||
wait_for_health(&base_url).await?;
|
||||
verify_remote_connect(&base_url, &client_token).await?;
|
||||
|
||||
println!();
|
||||
println!("Health check passed: {base_url}/health");
|
||||
println!("Remote connect URL: http://{connect_host}:{host_port}");
|
||||
println!("Remote connect token: {client_token}");
|
||||
println!("Host/admin token: {host_token}");
|
||||
println!("Workspace dir: {}", workspace_dir.display());
|
||||
println!("Data dir: {}", data_dir.display());
|
||||
println!("Sandbox logs are streaming below.");
|
||||
println!("Press Ctrl+C to stop the sandbox.");
|
||||
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.context("failed waiting for Ctrl+C")?;
|
||||
println!("Stopping microsandbox `{name}`...");
|
||||
sandbox
|
||||
.stop()
|
||||
.await
|
||||
.context("failed to stop microsandbox")?;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), log_task).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn env_flag(name: &str) -> bool {
|
||||
matches!(
|
||||
env::var(name).ok().as_deref(),
|
||||
Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES")
|
||||
)
|
||||
}
|
||||
|
||||
fn default_bind_dir(name: &str, suffix: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join(".state")
|
||||
.join(name)
|
||||
.join(suffix)
|
||||
}
|
||||
|
||||
async fn ensure_bind_dir(path: &Path) -> Result<()> {
|
||||
tokio::fs::create_dir_all(path)
|
||||
.await
|
||||
.with_context(|| format!("failed to create bind mount directory `{}`", path.display()))
|
||||
}
|
||||
|
||||
async fn wait_for_health(base_url: &str) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
let health_url = format!("{base_url}/health");
|
||||
|
||||
loop {
|
||||
match client.get(&health_url).send().await {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(_) | Err(_) if Instant::now() < deadline => {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
Ok(response) => {
|
||||
anyhow::bail!("health check failed with status {}", response.status());
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(error).context("health check never succeeded before timeout");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_remote_connect(base_url: &str, token: &str) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let workspaces_url = format!("{base_url}/workspaces");
|
||||
|
||||
let unauthorized = client
|
||||
.get(&workspaces_url)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to query workspaces without auth")?;
|
||||
if unauthorized.status() != StatusCode::UNAUTHORIZED {
|
||||
anyhow::bail!(
|
||||
"expected unauthenticated /workspaces to return 401, got {}",
|
||||
unauthorized.status()
|
||||
);
|
||||
}
|
||||
|
||||
let authorized = client
|
||||
.get(&workspaces_url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to query workspaces with client token")?;
|
||||
if !authorized.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"expected authenticated /workspaces to succeed, got {}",
|
||||
authorized.status()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{json, Value};
|
||||
use std::env::temp_dir;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires microsandbox runtime and a pullable OCI image"]
|
||||
async fn rust_example_smoke_test_checks_health_and_session_endpoints() -> Result<()> {
|
||||
let image = env::var("OPENWORK_MICROSANDBOX_IMAGE")
|
||||
.unwrap_or_else(|_| "ttl.sh/openwork-microsandbox-11559:1d".to_string());
|
||||
let connect_host = "127.0.0.1";
|
||||
let client_token = "some-shared-secret";
|
||||
let host_token = "some-owner-secret";
|
||||
let host_port = 28787;
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time went backwards")
|
||||
.as_millis();
|
||||
let short = unique % 1_000_000;
|
||||
let name = format!("owmsb-{short}");
|
||||
let base_dir = temp_dir().join(format!("owmsb-{short}"));
|
||||
let workspace_dir = base_dir.join("workspace");
|
||||
let data_dir = base_dir.join("data");
|
||||
|
||||
ensure_bind_dir(&workspace_dir).await?;
|
||||
ensure_bind_dir(&data_dir).await?;
|
||||
|
||||
let sandbox = Sandbox::builder(&name)
|
||||
.image(image.as_str())
|
||||
.replace()
|
||||
.memory(2048)
|
||||
.cpus(2)
|
||||
.env("OPENWORK_CONNECT_HOST", connect_host)
|
||||
.env("OPENWORK_TOKEN", client_token)
|
||||
.env("OPENWORK_HOST_TOKEN", host_token)
|
||||
.env("OPENWORK_APPROVAL_MODE", "auto")
|
||||
.port(host_port, 8787)
|
||||
.volume("/workspace", |v| {
|
||||
v.bind(workspace_dir.to_string_lossy().as_ref())
|
||||
})
|
||||
.volume("/data", |v| v.bind(data_dir.to_string_lossy().as_ref()))
|
||||
.network(|n| n.policy(NetworkPolicy::allow_all()))
|
||||
.create()
|
||||
.await?;
|
||||
|
||||
let server = sandbox
|
||||
.exec_stream(
|
||||
"/bin/sh",
|
||||
["-lc", "/usr/local/bin/microsandbox-entrypoint.sh"],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let log_task = tokio::spawn(async move {
|
||||
let mut server = server;
|
||||
while let Some(event) = server.recv().await {
|
||||
match event {
|
||||
ExecEvent::Stdout(data) => print!("{}", String::from_utf8_lossy(&data)),
|
||||
ExecEvent::Stderr(data) => eprint!("{}", String::from_utf8_lossy(&data)),
|
||||
ExecEvent::Exited { code } => {
|
||||
eprintln!("test microsandbox entrypoint exited with code {code}");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let base_url = format!("http://127.0.0.1:{host_port}");
|
||||
let result = async {
|
||||
wait_for_health(&base_url).await?;
|
||||
verify_remote_connect(&base_url, client_token).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let workspaces: Value = client
|
||||
.get(format!("{base_url}/workspaces"))
|
||||
.bearer_auth(client_token)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let workspace_id = workspaces
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.context("missing workspace id from /workspaces")?;
|
||||
|
||||
let created: Value = client
|
||||
.post(format!("{base_url}/w/{workspace_id}/opencode/session"))
|
||||
.bearer_auth(client_token)
|
||||
.json(&json!({ "title": "Rust microsandbox smoke test" }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let session_id = created
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.context("missing session id from session create response")?;
|
||||
|
||||
client
|
||||
.get(format!(
|
||||
"{base_url}/w/{workspace_id}/opencode/session/{session_id}"
|
||||
))
|
||||
.bearer_auth(client_token)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
client
|
||||
.get(format!(
|
||||
"{base_url}/w/{workspace_id}/opencode/session/{session_id}/message?limit=10"
|
||||
))
|
||||
.bearer_auth(client_token)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Result::<()>::Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let stop_result = sandbox.stop().await;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), log_task).await;
|
||||
stop_result?;
|
||||
result
|
||||
}
|
||||
}
|
||||
71
packaging/docker/Dockerfile.microsandbox
Normal file
71
packaging/docker/Dockerfile.microsandbox
Normal file
@@ -0,0 +1,71 @@
|
||||
FROM node:22-bookworm-slim AS openwork-builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git unzip \
|
||||
&& npm install -g bun \
|
||||
&& corepack enable \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter openwork-orchestrator... --filter openwork-server... \
|
||||
&& pnpm --filter openwork-orchestrator build:bin \
|
||||
&& pnpm --filter openwork-server build:bin
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
ARG OPENWORK_ORCHESTRATOR_VERSION
|
||||
ARG OPENWORK_SERVER_VERSION
|
||||
ARG OPENCODE_VERSION
|
||||
ARG OPENCODE_DOWNLOAD_URL=
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl tar unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=openwork-builder /src/apps/orchestrator/dist/bin/openwork /usr/local/bin/openwork
|
||||
COPY --from=openwork-builder /src/apps/server/dist/bin/openwork-server /usr/local/bin/openwork-server
|
||||
COPY --from=openwork-builder /src/constants.json /usr/local/constants.json
|
||||
COPY packaging/docker/microsandbox-entrypoint.sh /usr/local/bin/microsandbox-entrypoint.sh
|
||||
|
||||
RUN set -eux; \
|
||||
test -n "$OPENWORK_ORCHESTRATOR_VERSION"; \
|
||||
test -n "$OPENWORK_SERVER_VERSION"; \
|
||||
test -n "$OPENCODE_VERSION"; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in \
|
||||
amd64) asset="opencode-linux-x64-baseline.tar.gz" ;; \
|
||||
arm64) asset="opencode-linux-arm64.tar.gz" ;; \
|
||||
*) echo "unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
url="$OPENCODE_DOWNLOAD_URL"; \
|
||||
if [ -z "$url" ]; then \
|
||||
url="https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/${asset}"; \
|
||||
fi; \
|
||||
tmpdir="$(mktemp -d)"; \
|
||||
curl -fsSL "$url" -o "$tmpdir/$asset"; \
|
||||
tar -xzf "$tmpdir/$asset" -C "$tmpdir"; \
|
||||
binary="$(find "$tmpdir" -type f -name opencode | head -n 1)"; \
|
||||
test -n "$binary"; \
|
||||
install -m 0755 "$binary" /usr/local/bin/opencode; \
|
||||
chmod +x /usr/local/bin/microsandbox-entrypoint.sh; \
|
||||
rm -rf "$tmpdir"
|
||||
|
||||
RUN test "$(openwork --version)" = "$OPENWORK_ORCHESTRATOR_VERSION" \
|
||||
&& test "$(openwork-server --version)" = "$OPENWORK_SERVER_VERSION" \
|
||||
&& opencode --version
|
||||
|
||||
ENV OPENWORK_DATA_DIR=/data/openwork-orchestrator
|
||||
ENV OPENWORK_SIDECAR_DIR=/data/sidecars
|
||||
ENV OPENWORK_WORKSPACE=/workspace
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
VOLUME ["/workspace", "/data"]
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=12 \
|
||||
CMD /bin/sh -c 'curl -fsS "http://127.0.0.1:${OPENWORK_PORT:-8787}/health" >/dev/null || exit 1'
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/microsandbox-entrypoint.sh"]
|
||||
@@ -109,6 +109,42 @@ This is usually the fastest path for UI/auth/control-plane iteration because it
|
||||
|
||||
---
|
||||
|
||||
## Pre-baked Micro-Sandbox Image
|
||||
|
||||
For micro-sandbox work, use the pre-baked image that compiles `openwork` and `openwork-server` from source and downloads the pinned `opencode` binary during `docker build`.
|
||||
|
||||
Build it from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/build-microsandbox-openwork-image.sh
|
||||
```
|
||||
|
||||
Run it locally:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8787:8787 \
|
||||
-e OPENWORK_CONNECT_HOST=127.0.0.1 \
|
||||
openwork-microsandbox:dev
|
||||
```
|
||||
|
||||
Defaults:
|
||||
- `OPENWORK_TOKEN=microsandbox-token`
|
||||
- `OPENWORK_HOST_TOKEN=microsandbox-host-token`
|
||||
- `OPENWORK_APPROVAL_MODE=auto`
|
||||
|
||||
Verification:
|
||||
- Health: `curl http://127.0.0.1:8787/health`
|
||||
- Authenticated API call: `curl -H "Authorization: Bearer microsandbox-token" http://127.0.0.1:8787/workspaces`
|
||||
- Docker health: `docker inspect --format '{{json .State.Health}}' <container>`
|
||||
|
||||
Useful overrides:
|
||||
- `OPENWORK_TOKEN` — set your own client bearer token
|
||||
- `OPENWORK_HOST_TOKEN` — set your own host/admin token
|
||||
- `OPENWORK_CONNECT_HOST` — host name embedded in the printed connect URL
|
||||
- `DOCKER_PLATFORM` — optional platform passed to `docker build`
|
||||
|
||||
---
|
||||
|
||||
## Production container
|
||||
|
||||
This is a minimal packaging template to run the OpenWork Host contract in a single container.
|
||||
|
||||
60
packaging/docker/microsandbox-entrypoint.sh
Executable file
60
packaging/docker/microsandbox-entrypoint.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
OPENWORK_WORKSPACE="${OPENWORK_WORKSPACE:-/workspace}"
|
||||
OPENWORK_DATA_DIR="${OPENWORK_DATA_DIR:-/data/openwork-orchestrator}"
|
||||
OPENWORK_SIDECAR_DIR="${OPENWORK_SIDECAR_DIR:-/data/sidecars}"
|
||||
OPENWORK_PORT="${OPENWORK_PORT:-8787}"
|
||||
OPENWORK_OPENCODE_PORT="${OPENWORK_OPENCODE_PORT:-4096}"
|
||||
OPENWORK_TOKEN="${OPENWORK_TOKEN:-microsandbox-token}"
|
||||
OPENWORK_HOST_TOKEN="${OPENWORK_HOST_TOKEN:-microsandbox-host-token}"
|
||||
OPENWORK_APPROVAL_MODE="${OPENWORK_APPROVAL_MODE:-auto}"
|
||||
OPENWORK_CORS_ORIGINS="${OPENWORK_CORS_ORIGINS:-*}"
|
||||
OPENWORK_CONNECT_HOST="${OPENWORK_CONNECT_HOST:-127.0.0.1}"
|
||||
HOME="${HOME:-/root}"
|
||||
USER="${USER:-root}"
|
||||
SHELL="${SHELL:-/bin/sh}"
|
||||
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
|
||||
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
|
||||
|
||||
if [ "$HOME" = "/" ]; then
|
||||
HOME=/root
|
||||
XDG_CONFIG_HOME="$HOME/.config"
|
||||
XDG_CACHE_HOME="$HOME/.cache"
|
||||
XDG_DATA_HOME="$HOME/.local/share"
|
||||
XDG_STATE_HOME="$HOME/.local/state"
|
||||
fi
|
||||
|
||||
export HOME USER SHELL XDG_CONFIG_HOME XDG_CACHE_HOME XDG_DATA_HOME XDG_STATE_HOME
|
||||
|
||||
mkdir -p "$OPENWORK_WORKSPACE" "$OPENWORK_DATA_DIR" "$OPENWORK_SIDECAR_DIR"
|
||||
mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME"
|
||||
|
||||
printf '%s\n' "Starting OpenWork micro-sandbox"
|
||||
printf '%s\n' "- workspace: $OPENWORK_WORKSPACE"
|
||||
printf '%s\n' "- home: $HOME"
|
||||
printf '%s\n' "- openwork url: http://$OPENWORK_CONNECT_HOST:$OPENWORK_PORT"
|
||||
printf '%s\n' "- client token: $OPENWORK_TOKEN"
|
||||
printf '%s\n' "- host token: $OPENWORK_HOST_TOKEN"
|
||||
printf '%s\n' "- health: curl http://$OPENWORK_CONNECT_HOST:$OPENWORK_PORT/health"
|
||||
printf '%s\n' "- auth test: curl -H \"Authorization: Bearer $OPENWORK_TOKEN\" http://$OPENWORK_CONNECT_HOST:$OPENWORK_PORT/workspaces"
|
||||
|
||||
exec openwork serve \
|
||||
--workspace "$OPENWORK_WORKSPACE" \
|
||||
--remote-access \
|
||||
--openwork-port "$OPENWORK_PORT" \
|
||||
--opencode-host 127.0.0.1 \
|
||||
--opencode-port "$OPENWORK_OPENCODE_PORT" \
|
||||
--openwork-token "$OPENWORK_TOKEN" \
|
||||
--openwork-host-token "$OPENWORK_HOST_TOKEN" \
|
||||
--approval "$OPENWORK_APPROVAL_MODE" \
|
||||
--cors "$OPENWORK_CORS_ORIGINS" \
|
||||
--connect-host "$OPENWORK_CONNECT_HOST" \
|
||||
--allow-external \
|
||||
--sidecar-source external \
|
||||
--opencode-source external \
|
||||
--openwork-server-bin /usr/local/bin/openwork-server \
|
||||
--opencode-bin /usr/local/bin/opencode \
|
||||
--no-opencode-router
|
||||
40
scripts/build-microsandbox-openwork-image.sh
Executable file
40
scripts/build-microsandbox-openwork-image.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DOCKERFILE="$ROOT_DIR/packaging/docker/Dockerfile.microsandbox"
|
||||
|
||||
IMAGE_REF="${1:-openwork-microsandbox:dev}"
|
||||
DOCKER_PLATFORM="${DOCKER_PLATFORM:-}"
|
||||
OPENCODE_VERSION="${OPENCODE_VERSION:-$(node -e 'const fs=require("fs"); const parsed=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(String(parsed.opencodeVersion || "").trim().replace(/^v/, ""));' "$ROOT_DIR/constants.json")}"
|
||||
OPENWORK_ORCHESTRATOR_VERSION="${OPENWORK_ORCHESTRATOR_VERSION:-$(node -e 'const fs=require("fs"); const pkg=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(String(pkg.version));' "$ROOT_DIR/apps/orchestrator/package.json")}"
|
||||
OPENWORK_SERVER_VERSION="${OPENWORK_SERVER_VERSION:-$(node -e 'const fs=require("fs"); const pkg=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(String(pkg.version));' "$ROOT_DIR/apps/server/package.json")}"
|
||||
|
||||
args=(
|
||||
build
|
||||
-t "$IMAGE_REF"
|
||||
-f "$DOCKERFILE"
|
||||
--build-arg "OPENWORK_ORCHESTRATOR_VERSION=$OPENWORK_ORCHESTRATOR_VERSION"
|
||||
--build-arg "OPENWORK_SERVER_VERSION=$OPENWORK_SERVER_VERSION"
|
||||
--build-arg "OPENCODE_VERSION=$OPENCODE_VERSION"
|
||||
)
|
||||
|
||||
if [ -n "$DOCKER_PLATFORM" ]; then
|
||||
args+=(--platform "$DOCKER_PLATFORM")
|
||||
fi
|
||||
|
||||
args+=("$ROOT_DIR")
|
||||
|
||||
printf 'Building micro-sandbox image %s\n' "$IMAGE_REF"
|
||||
printf ' openwork-orchestrator@%s\n' "$OPENWORK_ORCHESTRATOR_VERSION"
|
||||
printf ' openwork-server@%s\n' "$OPENWORK_SERVER_VERSION"
|
||||
printf ' opencode@%s\n' "$OPENCODE_VERSION"
|
||||
|
||||
docker "${args[@]}"
|
||||
|
||||
printf '\nBuilt micro-sandbox image: %s\n' "$IMAGE_REF"
|
||||
printf 'Run example:\n'
|
||||
printf ' docker run --rm -p 8787:8787 -e OPENWORK_CONNECT_HOST=127.0.0.1 %s\n' "$IMAGE_REF"
|
||||
printf 'Verify:\n'
|
||||
printf ' curl http://127.0.0.1:8787/health\n'
|
||||
printf ' curl -H "Authorization: Bearer microsandbox-token" http://127.0.0.1:8787/workspaces\n'
|
||||
Reference in New Issue
Block a user