mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat(security): default local workers to localhost only (#1132)
Require generated OpenCode auth and explicit remote-sharing opt-in so local workers stay loopback-only unless the user intentionally exposes them.
This commit is contained in:
@@ -153,6 +153,7 @@ OpenWork therefore has two runtime connection modes:
|
||||
|
||||
- OpenWork runs on a desktop/laptop and can host OpenWork server surfaces locally.
|
||||
- The OpenCode server runs on loopback (default `127.0.0.1:4096`).
|
||||
- The OpenWork server also defaults to loopback-only access. Remote sharing is an explicit opt-in that rebinds the OpenWork server to `0.0.0.0` while keeping OpenCode on loopback.
|
||||
- OpenWork UI connects via the official SDK and listens to events.
|
||||
- `openwork-orchestrator` is the CLI host path for this mode.
|
||||
|
||||
@@ -173,6 +174,7 @@ This model keeps the user experience consistent across self-hosted and hosted pa
|
||||
- `openwork-orchestrator` (default): Tauri launches `openwork daemon run` and uses it for workspace activation plus OpenCode lifecycle.
|
||||
- `direct`: Tauri starts OpenCode directly.
|
||||
- In both desktop runtimes, OpenWork server (`/apps/server/`) is the API surface consumed by the UI; it is started with the resolved OpenCode base URL and proxies OpenCode and `opencode-router` routes.
|
||||
- Desktop-launched OpenCode credentials are always random, per-launch values generated by OpenWork. OpenCode stays on loopback and is intended to be reached through OpenWork server rather than exposed directly.
|
||||
- `opencode-router` is optional in desktop host mode and is started as a local service when messaging routes are enabled.
|
||||
|
||||
```text
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- Local-first, cloud-ready: OpenWork runs on your machine in one click. Send a message instantly.
|
||||
- Composable: desktop app, WhatsApp/Slack/Telegram connector, or server. Use what fits, no lock-in.
|
||||
- Ejectable: OpenWork is powered by OpenCode, so everything OpenCode can do works in OpenWork, even without a UI yet.
|
||||
- Sharing is caring: start solo, then share. One CLI or desktop command spins up an instantly shareable instance.
|
||||
- Sharing is caring: start solo on localhost, then explicitly opt into remote sharing when you need it.
|
||||
|
||||
<p align="center">
|
||||
<img src="./app-demo.gif" alt="OpenWork demo" width="800" />
|
||||
|
||||
@@ -941,6 +941,8 @@ export default function App() {
|
||||
const [clientDirectory, setClientDirectory] = createSignal("");
|
||||
|
||||
const [openworkServerSettings, setOpenworkServerSettings] = createSignal<OpenworkServerSettings>({});
|
||||
const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = createSignal(false);
|
||||
const [shareRemoteAccessError, setShareRemoteAccessError] = createSignal<string | null>(null);
|
||||
const [openworkServerUrl, setOpenworkServerUrl] = createSignal("");
|
||||
const [openworkServerStatus, setOpenworkServerStatus] = createSignal<OpenworkServerStatus>("disconnected");
|
||||
const [openworkServerCapabilities, setOpenworkServerCapabilities] = createSignal<OpenworkServerCapabilities | null>(null);
|
||||
@@ -4067,6 +4069,39 @@ export default function App() {
|
||||
setOpenworkServerSettings(stored);
|
||||
}
|
||||
|
||||
const saveShareRemoteAccess = async (enabled: boolean) => {
|
||||
if (shareRemoteAccessBusy()) return;
|
||||
const previous = openworkServerSettings();
|
||||
const next: OpenworkServerSettings = {
|
||||
...previous,
|
||||
remoteAccessEnabled: enabled,
|
||||
};
|
||||
|
||||
setShareRemoteAccessBusy(true);
|
||||
setShareRemoteAccessError(null);
|
||||
updateOpenworkServerSettings(next);
|
||||
|
||||
try {
|
||||
if (isTauriRuntime() && workspaceStore.activeWorkspaceDisplay().workspaceType === "local") {
|
||||
const restarted = await restartLocalServer();
|
||||
if (!restarted) {
|
||||
throw new Error("Failed to restart the local worker with the updated sharing setting.");
|
||||
}
|
||||
await reconnectOpenworkServer();
|
||||
}
|
||||
} catch (error) {
|
||||
updateOpenworkServerSettings(previous);
|
||||
setShareRemoteAccessError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update remote access.",
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
setShareRemoteAccessBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetOpenworkServerSettings = () => {
|
||||
clearOpenworkServerSettings();
|
||||
setOpenworkServerSettings({});
|
||||
@@ -7269,6 +7304,9 @@ export default function App() {
|
||||
reconnectOpenworkServer,
|
||||
openworkServerSettings: openworkServerSettings(),
|
||||
openworkServerHostInfo: openworkServerHostInfo(),
|
||||
shareRemoteAccessBusy: shareRemoteAccessBusy(),
|
||||
shareRemoteAccessError: shareRemoteAccessError(),
|
||||
saveShareRemoteAccess,
|
||||
openworkServerCapabilities: devtoolsCapabilities(),
|
||||
openworkServerDiagnostics: openworkServerDiagnostics(),
|
||||
openworkServerWorkspaceId: openworkServerWorkspaceId(),
|
||||
@@ -7552,6 +7590,9 @@ export default function App() {
|
||||
openworkServerDiagnostics: openworkServerDiagnostics(),
|
||||
openworkServerSettings: openworkServerSettings(),
|
||||
openworkServerHostInfo: openworkServerHostInfo(),
|
||||
shareRemoteAccessBusy: shareRemoteAccessBusy(),
|
||||
shareRemoteAccessError: shareRemoteAccessError(),
|
||||
saveShareRemoteAccess,
|
||||
openworkServerWorkspaceId: openworkServerWorkspaceId(),
|
||||
engineInfo: workspaceStore.engine(),
|
||||
engineDoctorVersion: workspaceStore.engineDoctorResult()?.version ?? null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
@@ -41,6 +41,12 @@ export default function ShareWorkspaceModal(props: {
|
||||
workspaceName: string;
|
||||
workspaceDetail?: string | null;
|
||||
fields: ShareField[];
|
||||
remoteAccess?: {
|
||||
enabled: boolean;
|
||||
busy: boolean;
|
||||
error?: string | null;
|
||||
onSave: (enabled: boolean) => void | Promise<void>;
|
||||
};
|
||||
note?: string | null;
|
||||
publisherBaseUrl?: string;
|
||||
onShareWorkspaceProfile?: () => void;
|
||||
@@ -62,6 +68,7 @@ export default function ShareWorkspaceModal(props: {
|
||||
const [revealedByIndex, setRevealedByIndex] = createSignal<Record<number, boolean>>({});
|
||||
const [copiedKey, setCopiedKey] = createSignal<string | null>(null);
|
||||
const [collaboratorExpanded, setCollaboratorExpanded] = createSignal(false);
|
||||
const [remoteAccessEnabled, setRemoteAccessEnabled] = createSignal(false);
|
||||
|
||||
const title = createMemo(() => props.title ?? "Share workspace");
|
||||
const note = createMemo(() => props.note?.trim() ?? "");
|
||||
@@ -71,13 +78,30 @@ export default function ShareWorkspaceModal(props: {
|
||||
accessFields().filter((field) => !isCollaboratorField(field.label)),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
setActiveView("chooser");
|
||||
setRevealedByIndex({});
|
||||
setCopiedKey(null);
|
||||
setCollaboratorExpanded(false);
|
||||
});
|
||||
createEffect(
|
||||
on(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
setActiveView("chooser");
|
||||
setRevealedByIndex({});
|
||||
setCopiedKey(null);
|
||||
setCollaboratorExpanded(false);
|
||||
setRemoteAccessEnabled(props.remoteAccess?.enabled === true);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.remoteAccess?.enabled,
|
||||
(enabled, previous) => {
|
||||
if (!props.open) return;
|
||||
if (enabled === previous) return;
|
||||
setRemoteAccessEnabled(enabled === true);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
@@ -330,9 +354,70 @@ export default function ShareWorkspaceModal(props: {
|
||||
<div class="space-y-6 pt-4 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div class="rounded-md border border-amber-6/40 bg-amber-3/30 px-3 py-2 text-[12px] text-amber-11 flex items-start gap-2">
|
||||
<span class="mt-0.5">⚠️</span>
|
||||
<span class="leading-relaxed">Share with trusted people only. These credentials grant live access to this workspace.</span>
|
||||
<span class="leading-relaxed">
|
||||
<Show
|
||||
when={props.remoteAccess}
|
||||
fallback={
|
||||
"Share with trusted people only. These credentials grant live access to this workspace."
|
||||
}
|
||||
>
|
||||
These credentials grant live access to this workspace. Sharing this workspace remotely may allow anyone with access to your network to control your worker.
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={props.remoteAccess}>
|
||||
{(remoteAccess) => {
|
||||
const hasPendingChange = () =>
|
||||
remoteAccessEnabled() !== remoteAccess().enabled;
|
||||
return (
|
||||
<div class="rounded-[20px] border border-dls-border bg-gray-2/30 px-4 py-4 space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-[13px] font-medium text-dls-text">Remote access</h3>
|
||||
<p class="text-[12px] text-gray-10 mt-0.5 leading-relaxed">
|
||||
Off by default. Turn this on only when you want this worker reachable from another machine.
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only peer"
|
||||
checked={remoteAccessEnabled()}
|
||||
onInput={(event) =>
|
||||
setRemoteAccessEnabled(event.currentTarget.checked)}
|
||||
disabled={remoteAccess().busy}
|
||||
/>
|
||||
<div class="w-11 h-6 rounded-full bg-gray-6 transition-colors peer-checked:bg-amber-8 peer-disabled:opacity-50 after:absolute after:top-[2px] after:left-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-transform peer-checked:after:translate-x-5" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-[12px] text-gray-10">
|
||||
{remoteAccess().enabled
|
||||
? "Remote access is currently enabled."
|
||||
: "Remote access is currently disabled."}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remoteAccess().onSave(remoteAccessEnabled())}
|
||||
disabled={remoteAccess().busy || !hasPendingChange()}
|
||||
class="px-3 py-1.5 bg-gray-2 hover:bg-gray-3 rounded-md text-[12px] font-medium text-dls-text transition-colors disabled:opacity-50"
|
||||
>
|
||||
{remoteAccess().busy ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={remoteAccess().error?.trim()}>
|
||||
<div class="rounded-md border border-red-6/40 bg-red-3/30 px-3 py-2 text-[12px] text-red-11">
|
||||
{remoteAccess().error}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-[20px] border border-dls-border bg-gray-2/30 px-3 py-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<MessageSquare size={16} class="text-gray-9 shrink-0" />
|
||||
@@ -351,9 +436,15 @@ export default function ShareWorkspaceModal(props: {
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Show when={primaryAccessFields().length > 0} fallback={
|
||||
<div class="rounded-[20px] border border-dls-border bg-gray-2/20 px-4 py-4 text-[12px] text-gray-10 leading-relaxed">
|
||||
Enable remote access and click Save to restart the worker and reveal the live connection details for this workspace.
|
||||
</div>
|
||||
}>
|
||||
<For each={primaryAccessFields()}>
|
||||
{(field, index) => renderCredentialField(field, index, "primary")}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={collaboratorField()}>
|
||||
|
||||
@@ -1252,6 +1252,7 @@ export function createWorkspaceStore(options: {
|
||||
opencodeBinPath:
|
||||
options.engineSource() === "custom" ? options.engineCustomBinPath?.().trim() || null : null,
|
||||
opencodeEnableExa: options.opencodeEnableExa?.() ?? false,
|
||||
openworkRemoteAccess: options.openworkServerSettings().remoteAccessEnabled === true,
|
||||
runtime,
|
||||
workspacePaths: resolveWorkspacePaths(),
|
||||
});
|
||||
@@ -2843,6 +2844,7 @@ export function createWorkspaceStore(options: {
|
||||
opencodeBinPath:
|
||||
options.engineSource() === "custom" ? options.engineCustomBinPath?.().trim() || null : null,
|
||||
opencodeEnableExa: options.opencodeEnableExa?.() ?? false,
|
||||
openworkRemoteAccess: options.openworkServerSettings().remoteAccessEnabled === true,
|
||||
runtime: resolveEngineRuntime(),
|
||||
workspacePaths: resolveWorkspacePaths(),
|
||||
});
|
||||
@@ -3041,6 +3043,7 @@ export function createWorkspaceStore(options: {
|
||||
opencodeBinPath:
|
||||
options.engineSource() === "custom" ? options.engineCustomBinPath?.().trim() || null : null,
|
||||
opencodeEnableExa: options.opencodeEnableExa?.() ?? false,
|
||||
openworkRemoteAccess: options.openworkServerSettings().remoteAccessEnabled === true,
|
||||
runtime,
|
||||
workspacePaths: resolveWorkspacePaths(),
|
||||
});
|
||||
|
||||
@@ -86,6 +86,7 @@ export type OpenworkServerSettings = {
|
||||
urlOverride?: string;
|
||||
portOverride?: number;
|
||||
token?: string;
|
||||
remoteAccessEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type OpenworkWorkspaceInfo = WorkspaceInfo & {
|
||||
@@ -526,6 +527,7 @@ export const DEFAULT_OPENWORK_SERVER_PORT = 8787;
|
||||
const STORAGE_URL_OVERRIDE = "openwork.server.urlOverride";
|
||||
const STORAGE_PORT_OVERRIDE = "openwork.server.port";
|
||||
const STORAGE_TOKEN = "openwork.server.token";
|
||||
const STORAGE_REMOTE_ACCESS = "openwork.server.remoteAccessEnabled";
|
||||
|
||||
export function normalizeOpenworkServerUrl(input: string) {
|
||||
const trimmed = input.trim();
|
||||
@@ -827,10 +829,12 @@ export function readOpenworkServerSettings(): OpenworkServerSettings {
|
||||
const portRaw = window.localStorage.getItem(STORAGE_PORT_OVERRIDE) ?? "";
|
||||
const portOverride = portRaw ? Number(portRaw) : undefined;
|
||||
const token = window.localStorage.getItem(STORAGE_TOKEN) ?? undefined;
|
||||
const remoteAccessRaw = window.localStorage.getItem(STORAGE_REMOTE_ACCESS) ?? "";
|
||||
return {
|
||||
urlOverride: urlOverride ?? undefined,
|
||||
portOverride: Number.isNaN(portOverride) ? undefined : portOverride,
|
||||
token: token?.trim() || undefined,
|
||||
remoteAccessEnabled: remoteAccessRaw === "1",
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -843,6 +847,7 @@ export function writeOpenworkServerSettings(next: OpenworkServerSettings): Openw
|
||||
const urlOverride = normalizeOpenworkServerUrl(next.urlOverride ?? "");
|
||||
const portOverride = typeof next.portOverride === "number" ? next.portOverride : undefined;
|
||||
const token = next.token?.trim() || undefined;
|
||||
const remoteAccessEnabled = next.remoteAccessEnabled === true;
|
||||
|
||||
if (urlOverride) {
|
||||
window.localStorage.setItem(STORAGE_URL_OVERRIDE, urlOverride);
|
||||
@@ -862,6 +867,12 @@ export function writeOpenworkServerSettings(next: OpenworkServerSettings): Openw
|
||||
window.localStorage.removeItem(STORAGE_TOKEN);
|
||||
}
|
||||
|
||||
if (remoteAccessEnabled) {
|
||||
window.localStorage.setItem(STORAGE_REMOTE_ACCESS, "1");
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_REMOTE_ACCESS);
|
||||
}
|
||||
|
||||
return readOpenworkServerSettings();
|
||||
} catch {
|
||||
return next;
|
||||
@@ -920,6 +931,7 @@ export function clearOpenworkServerSettings() {
|
||||
window.localStorage.removeItem(STORAGE_URL_OVERRIDE);
|
||||
window.localStorage.removeItem(STORAGE_PORT_OVERRIDE);
|
||||
window.localStorage.removeItem(STORAGE_TOKEN);
|
||||
window.localStorage.removeItem(STORAGE_REMOTE_ACCESS);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export type EngineInfo = {
|
||||
|
||||
export type OpenworkServerInfo = {
|
||||
running: boolean;
|
||||
remoteAccessEnabled: boolean;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
baseUrl: string | null;
|
||||
@@ -145,6 +146,7 @@ export async function engineStart(
|
||||
workspacePaths?: string[];
|
||||
opencodeBinPath?: string | null;
|
||||
opencodeEnableExa?: boolean;
|
||||
openworkRemoteAccess?: boolean;
|
||||
},
|
||||
): Promise<EngineInfo> {
|
||||
return invoke<EngineInfo>("engine_start", {
|
||||
@@ -152,6 +154,7 @@ export async function engineStart(
|
||||
preferSidecar: options?.preferSidecar ?? false,
|
||||
opencodeBinPath: options?.opencodeBinPath ?? null,
|
||||
opencodeEnableExa: options?.opencodeEnableExa ?? null,
|
||||
openworkRemoteAccess: options?.openworkRemoteAccess ?? null,
|
||||
runtime: options?.runtime ?? null,
|
||||
workspacePaths: options?.workspacePaths ?? null,
|
||||
});
|
||||
@@ -366,9 +369,11 @@ export async function engineStop(): Promise<EngineInfo> {
|
||||
|
||||
export async function engineRestart(options?: {
|
||||
opencodeEnableExa?: boolean;
|
||||
openworkRemoteAccess?: boolean;
|
||||
}): Promise<EngineInfo> {
|
||||
return invoke<EngineInfo>("engine_restart", {
|
||||
opencodeEnableExa: options?.opencodeEnableExa ?? null,
|
||||
openworkRemoteAccess: options?.openworkRemoteAccess ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -514,8 +519,12 @@ export async function openworkServerInfo(): Promise<OpenworkServerInfo> {
|
||||
return invoke<OpenworkServerInfo>("openwork_server_info");
|
||||
}
|
||||
|
||||
export async function openworkServerRestart(): Promise<OpenworkServerInfo> {
|
||||
return invoke<OpenworkServerInfo>("openwork_server_restart");
|
||||
export async function openworkServerRestart(options?: {
|
||||
remoteAccessEnabled?: boolean;
|
||||
}): Promise<OpenworkServerInfo> {
|
||||
return invoke<OpenworkServerInfo>("openwork_server_restart", {
|
||||
remoteAccessEnabled: options?.remoteAccessEnabled ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function engineInfo(): Promise<EngineInfo> {
|
||||
|
||||
@@ -124,9 +124,12 @@ export default function ConfigView(props: ConfigViewProps) {
|
||||
});
|
||||
|
||||
const hostInfo = createMemo(() => props.openworkServerHostInfo);
|
||||
const hostRemoteAccessEnabled = createMemo(
|
||||
() => hostInfo()?.remoteAccessEnabled === true,
|
||||
);
|
||||
const hostStatusLabel = createMemo(() => {
|
||||
if (!hostInfo()?.running) return "Offline";
|
||||
return "Available";
|
||||
return hostRemoteAccessEnabled() ? "Remote enabled" : "Local only";
|
||||
});
|
||||
const hostStatusStyle = createMemo(() => {
|
||||
if (!hostInfo()?.running) return "bg-gray-4/60 text-gray-11 border-gray-7/50";
|
||||
@@ -164,6 +167,7 @@ export default function ConfigView(props: ConfigViewProps) {
|
||||
host: host
|
||||
? {
|
||||
running: Boolean(host.running),
|
||||
remoteAccessEnabled: host.remoteAccessEnabled,
|
||||
baseUrl: host.baseUrl ?? null,
|
||||
connectUrl: host.connectUrl ?? null,
|
||||
mdnsUrl: host.mdnsUrl ?? null,
|
||||
@@ -342,7 +346,9 @@ export default function ConfigView(props: ConfigViewProps) {
|
||||
<div class="text-xs text-gray-7 font-mono truncate">{hostConnectUrl() || "Starting server…"}</div>
|
||||
<Show when={hostConnectUrl()}>
|
||||
<div class="text-[11px] text-gray-8 mt-1">
|
||||
{hostConnectUrlUsesMdns()
|
||||
{!hostRemoteAccessEnabled()
|
||||
? "Remote access is off. Use Share workspace to enable it before connecting from another machine."
|
||||
: hostConnectUrlUsesMdns()
|
||||
? ".local names are easier to remember but may not resolve on all networks."
|
||||
: "Use your local IP on the same Wi-Fi for the fastest connection."}
|
||||
</div>
|
||||
@@ -368,7 +374,11 @@ export default function ConfigView(props: ConfigViewProps) {
|
||||
? "••••••••••••"
|
||||
: "—"}
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-8 mt-1">Routine remote access for phones or laptops connecting to this server.</div>
|
||||
<div class="text-[11px] text-gray-8 mt-1">
|
||||
{hostRemoteAccessEnabled()
|
||||
? "Routine remote access for phones or laptops connecting to this server."
|
||||
: "Stored in advance for remote sharing, but remote access is currently disabled."}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
@@ -400,7 +410,11 @@ export default function ConfigView(props: ConfigViewProps) {
|
||||
? "••••••••••••"
|
||||
: "—"}
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-8 mt-1">Use this when a remote client needs to answer permission prompts or take owner-only actions.</div>
|
||||
<div class="text-[11px] text-gray-8 mt-1">
|
||||
{hostRemoteAccessEnabled()
|
||||
? "Use this when a remote client needs to answer permission prompts or take owner-only actions."
|
||||
: "Only relevant after you enable remote access for this worker."}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
|
||||
@@ -121,6 +121,9 @@ export type DashboardViewProps = {
|
||||
reconnectOpenworkServer: () => Promise<boolean>;
|
||||
openworkServerSettings: OpenworkServerSettings;
|
||||
openworkServerHostInfo: OpenworkServerInfo | null;
|
||||
shareRemoteAccessBusy: boolean;
|
||||
shareRemoteAccessError: string | null;
|
||||
saveShareRemoteAccess: (enabled: boolean) => Promise<void>;
|
||||
openworkServerCapabilities: OpenworkServerCapabilities | null;
|
||||
openworkServerDiagnostics: OpenworkServerDiagnostics | null;
|
||||
openworkServerWorkspaceId: string | null;
|
||||
@@ -685,6 +688,9 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
}
|
||||
|
||||
if (ws.workspaceType !== "remote") {
|
||||
if (props.openworkServerHostInfo?.remoteAccessEnabled !== true) {
|
||||
return [];
|
||||
}
|
||||
const hostUrl =
|
||||
props.openworkServerHostInfo?.connectUrl?.trim() ||
|
||||
props.openworkServerHostInfo?.lanUrl?.trim() ||
|
||||
@@ -1355,6 +1361,7 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
openworkServerUrl={props.openworkServerUrl}
|
||||
openworkReconnectBusy={props.openworkReconnectBusy}
|
||||
reconnectOpenworkServer={props.reconnectOpenworkServer}
|
||||
openworkServerSettings={props.openworkServerSettings}
|
||||
openworkServerHostInfo={props.openworkServerHostInfo}
|
||||
openworkServerCapabilities={props.openworkServerCapabilities}
|
||||
openworkServerDiagnostics={props.openworkServerDiagnostics}
|
||||
@@ -1512,6 +1519,14 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
workspaceName={shareWorkspaceName()}
|
||||
workspaceDetail={shareWorkspaceDetail()}
|
||||
fields={shareFields()}
|
||||
remoteAccess={shareWorkspace()?.workspaceType === "local"
|
||||
? {
|
||||
enabled: props.openworkServerHostInfo?.remoteAccessEnabled === true,
|
||||
busy: props.shareRemoteAccessBusy,
|
||||
error: props.shareRemoteAccessError,
|
||||
onSave: props.saveShareRemoteAccess,
|
||||
}
|
||||
: undefined}
|
||||
note={shareNote()}
|
||||
publisherBaseUrl={DEFAULT_OPENWORK_PUBLISHER_BASE_URL}
|
||||
onShareWorkspaceProfile={publishWorkspaceProfileLink}
|
||||
|
||||
@@ -146,6 +146,9 @@ export type SessionViewProps = {
|
||||
openworkServerDiagnostics: OpenworkServerDiagnostics | null;
|
||||
openworkServerSettings: OpenworkServerSettings;
|
||||
openworkServerHostInfo: OpenworkServerInfo | null;
|
||||
shareRemoteAccessBusy: boolean;
|
||||
shareRemoteAccessError: string | null;
|
||||
saveShareRemoteAccess: (enabled: boolean) => Promise<void>;
|
||||
openworkServerWorkspaceId: string | null;
|
||||
engineInfo: EngineInfo | null;
|
||||
engineDoctorVersion: string | null;
|
||||
@@ -3051,6 +3054,9 @@ export default function SessionView(props: SessionViewProps) {
|
||||
}
|
||||
|
||||
if (ws.workspaceType !== "remote") {
|
||||
if (props.openworkServerHostInfo?.remoteAccessEnabled !== true) {
|
||||
return [];
|
||||
}
|
||||
const hostUrl =
|
||||
props.openworkServerHostInfo?.connectUrl?.trim() ||
|
||||
props.openworkServerHostInfo?.lanUrl?.trim() ||
|
||||
@@ -4860,6 +4866,14 @@ export default function SessionView(props: SessionViewProps) {
|
||||
workspaceName={shareWorkspaceName()}
|
||||
workspaceDetail={shareWorkspaceDetail()}
|
||||
fields={shareFields()}
|
||||
remoteAccess={shareWorkspace()?.workspaceType === "local"
|
||||
? {
|
||||
enabled: props.openworkServerHostInfo?.remoteAccessEnabled === true,
|
||||
busy: props.shareRemoteAccessBusy,
|
||||
error: props.shareRemoteAccessError,
|
||||
onSave: props.saveShareRemoteAccess,
|
||||
}
|
||||
: undefined}
|
||||
note={shareNote()}
|
||||
publisherBaseUrl={DEFAULT_OPENWORK_PUBLISHER_BASE_URL}
|
||||
onShareWorkspaceProfile={publishWorkspaceProfileLink}
|
||||
|
||||
@@ -96,6 +96,7 @@ export type SettingsViewProps = {
|
||||
openworkServerUrl: string;
|
||||
openworkReconnectBusy: boolean;
|
||||
reconnectOpenworkServer: () => Promise<boolean>;
|
||||
openworkServerSettings: OpenworkServerSettings;
|
||||
openworkServerHostInfo: OpenworkServerInfo | null;
|
||||
openworkServerCapabilities: OpenworkServerCapabilities | null;
|
||||
openworkServerDiagnostics: OpenworkServerDiagnostics | null;
|
||||
@@ -747,7 +748,10 @@ export default function SettingsView(props: SettingsViewProps) {
|
||||
setOpenworkServerRestarting(true);
|
||||
setOpenworkServerRestartError(null);
|
||||
try {
|
||||
await openworkServerRestart();
|
||||
await openworkServerRestart({
|
||||
remoteAccessEnabled:
|
||||
props.openworkServerSettings.remoteAccessEnabled === true,
|
||||
});
|
||||
await props.reconnectOpenworkServer();
|
||||
} catch (e) {
|
||||
setOpenworkServerRestartError(e instanceof Error ? e.message : String(e));
|
||||
@@ -761,7 +765,11 @@ export default function SettingsView(props: SettingsViewProps) {
|
||||
setOpencodeRestarting(true);
|
||||
setOpencodeRestartError(null);
|
||||
try {
|
||||
await engineRestart({ opencodeEnableExa: props.opencodeEnableExa });
|
||||
await engineRestart({
|
||||
opencodeEnableExa: props.opencodeEnableExa,
|
||||
openworkRemoteAccess:
|
||||
props.openworkServerSettings.remoteAccessEnabled === true,
|
||||
});
|
||||
await props.reconnectOpenworkServer();
|
||||
} catch (e) {
|
||||
setOpencodeRestartError(e instanceof Error ? e.message : String(e));
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::engine::spawn::{find_free_port, spawn_engine};
|
||||
use crate::opencode_router::manager::OpenCodeRouterManager;
|
||||
use crate::opencode_router::spawn::resolve_opencode_router_health_port;
|
||||
use crate::openwork_server::{
|
||||
manager::OpenworkServerManager, resolve_connect_url, start_openwork_server,
|
||||
manager::OpenworkServerManager, start_openwork_server,
|
||||
};
|
||||
use crate::orchestrator::manager::OrchestratorManager;
|
||||
use crate::orchestrator::{self, OrchestratorSpawnOptions};
|
||||
@@ -20,6 +20,8 @@ use serde_json::json;
|
||||
use tauri_plugin_shell::process::CommandEvent;
|
||||
use uuid::Uuid;
|
||||
|
||||
const MANAGED_OPENCODE_CREDENTIAL_LENGTH: usize = 512;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<std::ffi::OsString>,
|
||||
@@ -90,6 +92,22 @@ struct OutputState {
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
fn generate_managed_opencode_secret() -> String {
|
||||
let mut value = String::with_capacity(MANAGED_OPENCODE_CREDENTIAL_LENGTH);
|
||||
while value.len() < MANAGED_OPENCODE_CREDENTIAL_LENGTH {
|
||||
value.push_str(&Uuid::new_v4().simple().to_string());
|
||||
}
|
||||
value.truncate(MANAGED_OPENCODE_CREDENTIAL_LENGTH);
|
||||
value
|
||||
}
|
||||
|
||||
fn generate_managed_opencode_credentials() -> (String, String) {
|
||||
(
|
||||
generate_managed_opencode_secret(),
|
||||
generate_managed_opencode_secret(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn engine_info(
|
||||
manager: State<EngineManager>,
|
||||
@@ -186,6 +204,7 @@ pub fn engine_restart(
|
||||
openwork_manager: State<OpenworkServerManager>,
|
||||
opencode_router_manager: State<OpenCodeRouterManager>,
|
||||
opencode_enable_exa: Option<bool>,
|
||||
openwork_remote_access: Option<bool>,
|
||||
) -> Result<EngineInfo, String> {
|
||||
let (project_dir, runtime) = {
|
||||
let state = manager.inner.lock().expect("engine mutex poisoned");
|
||||
@@ -209,6 +228,7 @@ pub fn engine_restart(
|
||||
None,
|
||||
None,
|
||||
opencode_enable_exa,
|
||||
openwork_remote_access,
|
||||
Some(runtime),
|
||||
Some(workspace_paths),
|
||||
)
|
||||
@@ -310,6 +330,7 @@ pub fn engine_start(
|
||||
prefer_sidecar: Option<bool>,
|
||||
opencode_bin_path: Option<String>,
|
||||
opencode_enable_exa: Option<bool>,
|
||||
openwork_remote_access: Option<bool>,
|
||||
runtime: Option<EngineRuntime>,
|
||||
workspace_paths: Option<Vec<String>>,
|
||||
) -> Result<EngineInfo, String> {
|
||||
@@ -343,27 +364,15 @@ pub fn engine_start(
|
||||
workspace_paths.retain(|path| path.trim() != project_dir);
|
||||
workspace_paths.insert(0, project_dir.clone());
|
||||
|
||||
let bind_host = std::env::var("OPENWORK_OPENCODE_BIND_HOST")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "0.0.0.0".to_string());
|
||||
let bind_host = "127.0.0.1".to_string();
|
||||
let client_host = "127.0.0.1".to_string();
|
||||
let port = find_free_port()?;
|
||||
let dev_mode = openwork_dev_mode_enabled();
|
||||
let enable_auth = std::env::var("OPENWORK_OPENCODE_AUTH")
|
||||
.ok()
|
||||
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(true);
|
||||
let opencode_username = if enable_auth {
|
||||
Some("opencode".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let opencode_password = if enable_auth {
|
||||
Some(Uuid::new_v4().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let openwork_remote_access_enabled = openwork_remote_access.unwrap_or(false);
|
||||
let (managed_opencode_username, managed_opencode_password) =
|
||||
generate_managed_opencode_credentials();
|
||||
let opencode_username = Some(managed_opencode_username);
|
||||
let opencode_password = Some(managed_opencode_password);
|
||||
|
||||
let mut state = manager.inner.lock().expect("engine mutex poisoned");
|
||||
EngineManager::stop_locked(&mut state);
|
||||
@@ -505,8 +514,7 @@ pub fn engine_start(
|
||||
.ok_or_else(|| "Orchestrator did not report OpenCode status".to_string())?;
|
||||
let opencode_port = opencode.port;
|
||||
let opencode_base_url = format!("http://127.0.0.1:{opencode_port}");
|
||||
let opencode_connect_url =
|
||||
resolve_connect_url(opencode_port).unwrap_or_else(|| opencode_base_url.clone());
|
||||
let opencode_connect_url = opencode_base_url.clone();
|
||||
|
||||
if let Ok(mut state) = manager.inner.lock() {
|
||||
state.runtime = EngineRuntime::Orchestrator;
|
||||
@@ -543,6 +551,7 @@ pub fn engine_start(
|
||||
opencode_username.as_deref(),
|
||||
opencode_password.as_deref(),
|
||||
opencode_router_health_port,
|
||||
openwork_remote_access_enabled,
|
||||
) {
|
||||
if let Ok(mut state) = manager.inner.lock() {
|
||||
state.last_stderr =
|
||||
@@ -705,7 +714,7 @@ pub fn engine_start(
|
||||
state.opencode_password = opencode_password.clone();
|
||||
|
||||
let opencode_connect_url =
|
||||
resolve_connect_url(port).unwrap_or_else(|| format!("http://{client_host}:{port}"));
|
||||
format!("http://{client_host}:{port}");
|
||||
let opencode_router_health_port = match resolve_opencode_router_health_port() {
|
||||
Ok(port) => Some(port),
|
||||
Err(error) => {
|
||||
@@ -725,6 +734,7 @@ pub fn engine_start(
|
||||
opencode_username.as_deref(),
|
||||
opencode_password.as_deref(),
|
||||
opencode_router_health_port,
|
||||
openwork_remote_access_enabled,
|
||||
) {
|
||||
state.last_stderr = Some(truncate_output(&format!("OpenWork server: {error}"), 8000));
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ pub fn openwork_server_restart(
|
||||
manager: State<OpenworkServerManager>,
|
||||
engine_manager: State<EngineManager>,
|
||||
opencode_router_manager: State<OpenCodeRouterManager>,
|
||||
remote_access_enabled: Option<bool>,
|
||||
) -> Result<OpenworkServerInfo, String> {
|
||||
let (workspace_path, opencode_url, opencode_username, opencode_password) = {
|
||||
let engine = engine_manager
|
||||
@@ -52,5 +53,6 @@ pub fn openwork_server_restart(
|
||||
opencode_username.as_deref(),
|
||||
opencode_password.as_deref(),
|
||||
opencode_router_health_port,
|
||||
remote_access_enabled.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -850,10 +850,9 @@ pub fn orchestrator_start_detached(
|
||||
workspace_path.clone(),
|
||||
"--approval".to_string(),
|
||||
"auto".to_string(),
|
||||
"--no-opencode-auth".to_string(),
|
||||
"--opencode-router".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(),
|
||||
|
||||
@@ -13,6 +13,7 @@ pub struct OpenworkServerManager {
|
||||
pub struct OpenworkServerState {
|
||||
pub child: Option<CommandChild>,
|
||||
pub child_exited: bool,
|
||||
pub remote_access_enabled: bool,
|
||||
pub host: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub base_url: Option<String>,
|
||||
@@ -39,6 +40,7 @@ impl OpenworkServerManager {
|
||||
|
||||
OpenworkServerInfo {
|
||||
running,
|
||||
remote_access_enabled: state.remote_access_enabled,
|
||||
host: state.host.clone(),
|
||||
port: state.port,
|
||||
base_url: state.base_url.clone(),
|
||||
@@ -59,6 +61,7 @@ impl OpenworkServerManager {
|
||||
let _ = child.kill();
|
||||
}
|
||||
state.child_exited = true;
|
||||
state.remote_access_enabled = false;
|
||||
state.host = None;
|
||||
state.port = None;
|
||||
state.base_url = None;
|
||||
|
||||
@@ -198,11 +198,6 @@ fn build_urls(port: u16) -> (Option<String>, Option<String>, Option<String>) {
|
||||
(connect_url, mdns_url, lan_url)
|
||||
}
|
||||
|
||||
pub fn resolve_connect_url(port: u16) -> Option<String> {
|
||||
let (connect_url, _mdns_url, _lan_url) = build_urls(port);
|
||||
connect_url
|
||||
}
|
||||
|
||||
pub fn start_openwork_server(
|
||||
app: &AppHandle,
|
||||
manager: &OpenworkServerManager,
|
||||
@@ -211,6 +206,7 @@ pub fn start_openwork_server(
|
||||
opencode_username: Option<&str>,
|
||||
opencode_password: Option<&str>,
|
||||
opencode_router_health_port: Option<u16>,
|
||||
remote_access_enabled: bool,
|
||||
) -> Result<OpenworkServerInfo, String> {
|
||||
let mut state = manager
|
||||
.inner
|
||||
@@ -218,8 +214,12 @@ pub fn start_openwork_server(
|
||||
.map_err(|_| "openwork server mutex poisoned".to_string())?;
|
||||
OpenworkServerManager::stop_locked(&mut state);
|
||||
|
||||
let host = "0.0.0.0".to_string();
|
||||
let port = resolve_openwork_port()?;
|
||||
let host = if remote_access_enabled {
|
||||
"0.0.0.0".to_string()
|
||||
} else {
|
||||
"127.0.0.1".to_string()
|
||||
};
|
||||
let port = resolve_openwork_port(&host)?;
|
||||
let active_workspace = workspace_paths
|
||||
.first()
|
||||
.map(|path| path.as_str())
|
||||
@@ -248,6 +248,7 @@ pub fn start_openwork_server(
|
||||
|
||||
state.child = Some(child);
|
||||
state.child_exited = false;
|
||||
state.remote_access_enabled = remote_access_enabled;
|
||||
state.host = Some(host.clone());
|
||||
state.port = Some(port);
|
||||
state.base_url = Some(format!("http://127.0.0.1:{port}"));
|
||||
@@ -255,7 +256,11 @@ pub fn start_openwork_server(
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("http://127.0.0.1:{port}"));
|
||||
let (connect_url, mdns_url, lan_url) = build_urls(port);
|
||||
let (connect_url, mdns_url, lan_url) = if remote_access_enabled {
|
||||
build_urls(port)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
state.connect_url = connect_url;
|
||||
state.mdns_url = mdns_url;
|
||||
state.lan_url = lan_url;
|
||||
|
||||
@@ -8,11 +8,11 @@ use tauri_plugin_shell::ShellExt;
|
||||
|
||||
const DEFAULT_OPENWORK_PORT: u16 = 8787;
|
||||
|
||||
pub fn resolve_openwork_port() -> Result<u16, String> {
|
||||
if TcpListener::bind(("0.0.0.0", DEFAULT_OPENWORK_PORT)).is_ok() {
|
||||
pub fn resolve_openwork_port(host: &str) -> Result<u16, String> {
|
||||
if TcpListener::bind((host, DEFAULT_OPENWORK_PORT)).is_ok() {
|
||||
return Ok(DEFAULT_OPENWORK_PORT);
|
||||
}
|
||||
let listener = TcpListener::bind(("0.0.0.0", 0)).map_err(|e| e.to_string())?;
|
||||
let listener = TcpListener::bind((host, 0)).map_err(|e| e.to_string())?;
|
||||
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
@@ -279,6 +279,8 @@ pub fn spawn_orchestrator_daemon(
|
||||
command = command.env(key, value);
|
||||
}
|
||||
|
||||
command = command.env("OPENWORK_INTERNAL_ALLOW_OPENCODE_CREDENTIALS", "1");
|
||||
|
||||
if options.dev_mode {
|
||||
command = command.env("OPENWORK_DEV_MODE", "1");
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ pub struct EngineInfo {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenworkServerInfo {
|
||||
pub running: bool,
|
||||
pub remote_access_enabled: bool,
|
||||
pub host: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub base_url: Option<String>,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type ChildProcess,
|
||||
type SpawnOptions,
|
||||
} from "node:child_process";
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import { randomBytes, randomUUID, createHash } from "node:crypto";
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
@@ -130,7 +130,9 @@ declare const __OPENWORK_ORCHESTRATOR_VERSION__: string | undefined;
|
||||
declare const __OPENWORK_PINNED_OPENCODE_VERSION__: string | undefined;
|
||||
const DEFAULT_OPENWORK_PORT = 8787;
|
||||
const DEFAULT_APPROVAL_TIMEOUT = 30000;
|
||||
const DEFAULT_OPENCODE_USERNAME = "opencode";
|
||||
const MANAGED_OPENCODE_CREDENTIAL_LENGTH = 512;
|
||||
const INTERNAL_OPENCODE_CREDENTIALS_ENV =
|
||||
"OPENWORK_INTERNAL_ALLOW_OPENCODE_CREDENTIALS";
|
||||
const DEFAULT_OPENCODE_HOT_RELOAD_DEBOUNCE_MS = 700;
|
||||
const DEFAULT_OPENCODE_HOT_RELOAD_COOLDOWN_MS = 1500;
|
||||
const DEFAULT_ACTIVITY_WINDOW_MS = 5 * 60_000;
|
||||
@@ -1213,6 +1215,129 @@ function encodeBasicAuth(username: string, password: string): string {
|
||||
return Buffer.from(`${username}:${password}`, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
const normalized = host.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "127.0.0.1" ||
|
||||
normalized === "localhost" ||
|
||||
normalized === "::1"
|
||||
);
|
||||
}
|
||||
|
||||
function randomCredential(length: number): string {
|
||||
return randomBytes(Math.ceil(length / 2)).toString("hex").slice(0, length);
|
||||
}
|
||||
|
||||
function generateManagedOpencodeCredentials(): {
|
||||
username: string;
|
||||
password: string;
|
||||
} {
|
||||
return {
|
||||
username: randomCredential(MANAGED_OPENCODE_CREDENTIAL_LENGTH),
|
||||
password: randomCredential(MANAGED_OPENCODE_CREDENTIAL_LENGTH),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveManagedOpencodeCredentials(args: ParsedArgs): {
|
||||
username: string;
|
||||
password: string;
|
||||
} {
|
||||
const explicitUsernameFlag = args.flags.get("opencode-username");
|
||||
const explicitPasswordFlag = args.flags.get("opencode-password");
|
||||
const requestedUsername =
|
||||
typeof explicitUsernameFlag === "string"
|
||||
? explicitUsernameFlag
|
||||
: process.env.OPENWORK_OPENCODE_USERNAME ??
|
||||
process.env.OPENCODE_SERVER_USERNAME;
|
||||
const requestedPassword =
|
||||
typeof explicitPasswordFlag === "string"
|
||||
? explicitPasswordFlag
|
||||
: process.env.OPENWORK_OPENCODE_PASSWORD ??
|
||||
process.env.OPENCODE_SERVER_PASSWORD;
|
||||
const allowInjectedCredentials =
|
||||
(process.env[INTERNAL_OPENCODE_CREDENTIALS_ENV] ?? "").trim() === "1";
|
||||
const hasExplicitCredentialFlags =
|
||||
typeof explicitUsernameFlag === "string" ||
|
||||
typeof explicitPasswordFlag === "string";
|
||||
|
||||
if (
|
||||
hasExplicitCredentialFlags &&
|
||||
((requestedUsername && !requestedPassword) ||
|
||||
(!requestedUsername && requestedPassword))
|
||||
) {
|
||||
throw new Error(
|
||||
"OpenCode credentials must include both username and password.",
|
||||
);
|
||||
}
|
||||
|
||||
if (requestedUsername && requestedPassword && hasExplicitCredentialFlags) {
|
||||
if (!allowInjectedCredentials) {
|
||||
throw new Error(
|
||||
"OpenCode credentials are managed by OpenWork. Custom --opencode-username/--opencode-password values are not supported.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
username: requestedUsername,
|
||||
password: requestedPassword,
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedUsername && requestedPassword && allowInjectedCredentials) {
|
||||
return {
|
||||
username: requestedUsername,
|
||||
password: requestedPassword,
|
||||
};
|
||||
}
|
||||
|
||||
return generateManagedOpencodeCredentials();
|
||||
}
|
||||
|
||||
function assertManagedOpencodeAuth(args: ParsedArgs) {
|
||||
const authEnabled = readBool(
|
||||
args.flags,
|
||||
"opencode-auth",
|
||||
true,
|
||||
"OPENWORK_OPENCODE_AUTH",
|
||||
);
|
||||
if (!authEnabled) {
|
||||
throw new Error(
|
||||
"OpenCode basic auth is always enabled when OpenWork launches OpenCode.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveManagedOpencodeHost(requestedHost?: string): string {
|
||||
const normalized = requestedHost?.trim();
|
||||
if (!normalized) return "127.0.0.1";
|
||||
if (!isLoopbackHost(normalized)) {
|
||||
throw new Error(
|
||||
`OpenCode must stay on loopback. Unsupported --opencode-host value: ${normalized}`,
|
||||
);
|
||||
}
|
||||
return normalized === "localhost" ? "127.0.0.1" : normalized;
|
||||
}
|
||||
|
||||
function resolveOpenworkRemoteAccess(args: ParsedArgs): boolean {
|
||||
const explicitHost =
|
||||
readFlag(args.flags, "openwork-host") ?? process.env.OPENWORK_HOST;
|
||||
const remoteAccessRequested =
|
||||
readBool(args.flags, "remote-access", false, "OPENWORK_REMOTE_ACCESS") ||
|
||||
explicitHost?.trim() === "0.0.0.0";
|
||||
|
||||
if (explicitHost) {
|
||||
const normalized = explicitHost.trim();
|
||||
if (!normalized) return remoteAccessRequested;
|
||||
if (normalized === "0.0.0.0") return true;
|
||||
if (!isLoopbackHost(normalized)) {
|
||||
throw new Error(
|
||||
`Unsupported --openwork-host value: ${normalized}. Use loopback by default or --remote-access for shared access.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return remoteAccessRequested;
|
||||
}
|
||||
|
||||
function unwrap<T>(result: FieldsResult<T>): T {
|
||||
if (result.data !== undefined) {
|
||||
return result.data;
|
||||
@@ -3455,18 +3580,18 @@ function printHelp(): void {
|
||||
" --daemon-host <host> Host for orchestrator router daemon (default: 127.0.0.1)",
|
||||
" --daemon-port <port> Port for orchestrator router daemon (default: random)",
|
||||
" --opencode-bin <path> Path to opencode binary (requires --allow-external)",
|
||||
" --opencode-host <host> Bind host for opencode serve (default: 0.0.0.0)",
|
||||
" --opencode-host <host> Bind host for opencode serve (loopback only, default: 127.0.0.1)",
|
||||
" --opencode-port <port> Port for opencode serve (default: random)",
|
||||
" --opencode-workdir <p> Workdir for router-managed opencode serve",
|
||||
" --opencode-auth Enable OpenCode basic auth (default: true)",
|
||||
" --no-opencode-auth Disable OpenCode basic auth",
|
||||
" --opencode-auth OpenCode basic auth is always enabled",
|
||||
" --opencode-hot-reload Enable OpenCode hot reload (default: true)",
|
||||
" --opencode-hot-reload-debounce-ms <ms> Debounce window for hot reload triggers (default: 700)",
|
||||
" --opencode-hot-reload-cooldown-ms <ms> Minimum interval between hot reloads (default: 1500)",
|
||||
" --opencode-username <u> OpenCode basic auth username",
|
||||
" --opencode-password <p> OpenCode basic auth password",
|
||||
" --openwork-host <host> Bind host for openwork-server (default: 0.0.0.0)",
|
||||
" --opencode-username <u> Internal-only override for managed OpenCode auth username",
|
||||
" --opencode-password <p> Internal-only override for managed OpenCode auth password",
|
||||
" --openwork-host <host> Bind host for openwork-server (default: 127.0.0.1)",
|
||||
" --openwork-port <port> Port for openwork-server (default: 8787)",
|
||||
" --remote-access Expose OpenWork on 0.0.0.0 for remote sharing",
|
||||
" --openwork-token <token> Client token for openwork-server",
|
||||
" --openwork-host-token <t> Host token for approvals",
|
||||
" --workspace-id <id> Workspace id for file session commands",
|
||||
@@ -4227,7 +4352,7 @@ async function startDockerSandbox(options: {
|
||||
"--name",
|
||||
options.containerName,
|
||||
"-p",
|
||||
`${options.ports.openwork}:${SANDBOX_INTERNAL_OPENWORK_PORT}`,
|
||||
`127.0.0.1:${options.ports.openwork}:${SANDBOX_INTERNAL_OPENWORK_PORT}`,
|
||||
"-v",
|
||||
`${options.workspace}:/workspace`,
|
||||
"-v",
|
||||
@@ -4272,7 +4397,7 @@ async function startDockerSandbox(options: {
|
||||
if (options.sidecars.opencodeRouter && options.ports.opencodeRouterHealth) {
|
||||
args.push(
|
||||
"-p",
|
||||
`${options.ports.opencodeRouterHealth}:${SANDBOX_INTERNAL_OPENCODE_ROUTER_HEALTH_PORT}`,
|
||||
`127.0.0.1:${options.ports.opencodeRouterHealth}:${SANDBOX_INTERNAL_OPENCODE_ROUTER_HEALTH_PORT}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4389,7 +4514,7 @@ async function startAppleContainerSandbox(options: {
|
||||
"--name",
|
||||
options.containerName,
|
||||
"-p",
|
||||
`${options.ports.openwork}:${SANDBOX_INTERNAL_OPENWORK_PORT}`,
|
||||
`127.0.0.1:${options.ports.openwork}:${SANDBOX_INTERNAL_OPENWORK_PORT}`,
|
||||
"-v",
|
||||
`${options.workspace}:/workspace`,
|
||||
"-v",
|
||||
@@ -4434,7 +4559,7 @@ async function startAppleContainerSandbox(options: {
|
||||
if (options.sidecars.opencodeRouter && options.ports.opencodeRouterHealth) {
|
||||
args.push(
|
||||
"-p",
|
||||
`${options.ports.opencodeRouterHealth}:${SANDBOX_INTERNAL_OPENCODE_ROUTER_HEALTH_PORT}`,
|
||||
`127.0.0.1:${options.ports.opencodeRouterHealth}:${SANDBOX_INTERNAL_OPENCODE_ROUTER_HEALTH_PORT}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5165,11 +5290,7 @@ function buildAttachCommand(input: {
|
||||
password?: string;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (
|
||||
input.username &&
|
||||
input.password &&
|
||||
input.username !== DEFAULT_OPENCODE_USERNAME
|
||||
) {
|
||||
if (input.username && input.password) {
|
||||
parts.push(`OPENCODE_SERVER_USERNAME=${input.username}`);
|
||||
}
|
||||
if (input.password) {
|
||||
@@ -5241,8 +5362,10 @@ async function spawnRouterDaemon(
|
||||
|
||||
const opencodeBin =
|
||||
readFlag(args.flags, "opencode-bin") ?? process.env.OPENWORK_OPENCODE_BIN;
|
||||
const opencodeHost =
|
||||
readFlag(args.flags, "opencode-host") ?? process.env.OPENWORK_OPENCODE_HOST;
|
||||
assertManagedOpencodeAuth(args);
|
||||
const opencodeHost = resolveManagedOpencodeHost(
|
||||
readFlag(args.flags, "opencode-host") ?? process.env.OPENWORK_OPENCODE_HOST,
|
||||
);
|
||||
const opencodePort =
|
||||
readFlag(args.flags, "opencode-port") ?? process.env.OPENWORK_OPENCODE_PORT;
|
||||
const opencodeWorkdir =
|
||||
@@ -5257,12 +5380,9 @@ async function spawnRouterDaemon(
|
||||
const opencodeHotReloadCooldownMs =
|
||||
readFlag(args.flags, "opencode-hot-reload-cooldown-ms") ??
|
||||
process.env.OPENWORK_OPENCODE_HOT_RELOAD_COOLDOWN_MS;
|
||||
const opencodeUsername =
|
||||
readFlag(args.flags, "opencode-username") ??
|
||||
process.env.OPENWORK_OPENCODE_USERNAME;
|
||||
const opencodePassword =
|
||||
readFlag(args.flags, "opencode-password") ??
|
||||
process.env.OPENWORK_OPENCODE_PASSWORD;
|
||||
const opencodeCredentials = resolveManagedOpencodeCredentials(args);
|
||||
const opencodeUsername = opencodeCredentials.username;
|
||||
const opencodePassword = opencodeCredentials.password;
|
||||
const corsValue =
|
||||
readFlag(args.flags, "cors") ?? process.env.OPENWORK_OPENCODE_CORS;
|
||||
const allowExternal = readBool(
|
||||
@@ -5298,10 +5418,8 @@ async function spawnRouterDaemon(
|
||||
"--opencode-hot-reload-cooldown-ms",
|
||||
String(opencodeHotReloadCooldownMs),
|
||||
);
|
||||
if (opencodeUsername)
|
||||
commandArgs.push("--opencode-username", opencodeUsername);
|
||||
if (opencodePassword)
|
||||
commandArgs.push("--opencode-password", opencodePassword);
|
||||
commandArgs.push("--opencode-username", opencodeCredentials.username);
|
||||
commandArgs.push("--opencode-password", opencodeCredentials.password);
|
||||
if (corsValue) commandArgs.push("--cors", corsValue);
|
||||
if (allowExternal) commandArgs.push("--allow-external");
|
||||
if (sidecarSource) commandArgs.push("--sidecar-source", sidecarSource);
|
||||
@@ -5559,24 +5677,16 @@ async function runRouterDaemon(args: ParsedArgs) {
|
||||
|
||||
const opencodeBin =
|
||||
readFlag(args.flags, "opencode-bin") ?? process.env.OPENWORK_OPENCODE_BIN;
|
||||
const opencodeHost =
|
||||
readFlag(args.flags, "opencode-host") ??
|
||||
process.env.OPENWORK_OPENCODE_HOST ??
|
||||
"127.0.0.1";
|
||||
const opencodePassword =
|
||||
readFlag(args.flags, "opencode-password") ??
|
||||
process.env.OPENWORK_OPENCODE_PASSWORD ??
|
||||
process.env.OPENCODE_SERVER_PASSWORD;
|
||||
const opencodeUsername =
|
||||
readFlag(args.flags, "opencode-username") ??
|
||||
process.env.OPENWORK_OPENCODE_USERNAME ??
|
||||
process.env.OPENCODE_SERVER_USERNAME ??
|
||||
DEFAULT_OPENCODE_USERNAME;
|
||||
const authHeaders = opencodePassword
|
||||
? {
|
||||
Authorization: `Basic ${encodeBasicAuth(opencodeUsername, opencodePassword)}`,
|
||||
}
|
||||
: undefined;
|
||||
assertManagedOpencodeAuth(args);
|
||||
const opencodeHost = resolveManagedOpencodeHost(
|
||||
readFlag(args.flags, "opencode-host") ?? process.env.OPENWORK_OPENCODE_HOST,
|
||||
);
|
||||
const opencodeCredentials = resolveManagedOpencodeCredentials(args);
|
||||
const opencodeUsername = opencodeCredentials.username;
|
||||
const opencodePassword = opencodeCredentials.password;
|
||||
const authHeaders = {
|
||||
Authorization: `Basic ${encodeBasicAuth(opencodeCredentials.username, opencodeCredentials.password)}`,
|
||||
};
|
||||
const opencodePort = await resolvePort(
|
||||
readNumber(
|
||||
args.flags,
|
||||
@@ -5713,8 +5823,8 @@ async function runRouterDaemon(args: ParsedArgs) {
|
||||
hotReload: opencodeHotReload,
|
||||
bindHost: opencodeHost,
|
||||
port: opencodePort,
|
||||
username: opencodePassword ? opencodeUsername : undefined,
|
||||
password: opencodePassword,
|
||||
username: opencodeCredentials.username,
|
||||
password: opencodeCredentials.password,
|
||||
corsOrigins: corsOrigins.length ? corsOrigins : ["*"],
|
||||
logger,
|
||||
runId,
|
||||
@@ -6642,10 +6752,11 @@ async function runStart(args: ParsedArgs) {
|
||||
const explicitOpenCodeRouterBin =
|
||||
readFlag(args.flags, "opencode-router-bin") ??
|
||||
process.env.OPENCODE_ROUTER_BIN;
|
||||
const opencodeBindHost =
|
||||
assertManagedOpencodeAuth(args);
|
||||
const opencodeBindHost = resolveManagedOpencodeHost(
|
||||
readFlag(args.flags, "opencode-host") ??
|
||||
process.env.OPENWORK_OPENCODE_BIND_HOST ??
|
||||
"0.0.0.0";
|
||||
process.env.OPENWORK_OPENCODE_BIND_HOST,
|
||||
);
|
||||
const opencodePort =
|
||||
sandboxMode !== "none"
|
||||
? SANDBOX_INTERNAL_OPENCODE_PORT
|
||||
@@ -6671,27 +6782,12 @@ async function runStart(args: ParsedArgs) {
|
||||
cooldownMs: "OPENWORK_OPENCODE_HOT_RELOAD_COOLDOWN_MS",
|
||||
},
|
||||
);
|
||||
const opencodeAuth = readBool(
|
||||
args.flags,
|
||||
"opencode-auth",
|
||||
true,
|
||||
"OPENWORK_OPENCODE_AUTH",
|
||||
);
|
||||
const opencodeUsername = opencodeAuth
|
||||
? (readFlag(args.flags, "opencode-username") ??
|
||||
process.env.OPENWORK_OPENCODE_USERNAME ??
|
||||
DEFAULT_OPENCODE_USERNAME)
|
||||
: undefined;
|
||||
const opencodePassword = opencodeAuth
|
||||
? (readFlag(args.flags, "opencode-password") ??
|
||||
process.env.OPENWORK_OPENCODE_PASSWORD ??
|
||||
randomUUID())
|
||||
: undefined;
|
||||
const opencodeCredentials = resolveManagedOpencodeCredentials(args);
|
||||
const opencodeUsername = opencodeCredentials.username;
|
||||
const opencodePassword = opencodeCredentials.password;
|
||||
|
||||
const openworkHost =
|
||||
readFlag(args.flags, "openwork-host") ??
|
||||
process.env.OPENWORK_HOST ??
|
||||
"0.0.0.0";
|
||||
const remoteAccessEnabled = resolveOpenworkRemoteAccess(args);
|
||||
const openworkHost = remoteAccessEnabled ? "0.0.0.0" : "127.0.0.1";
|
||||
const openworkPort = await resolvePort(
|
||||
readNumber(args.flags, "openwork-port", undefined, "OPENWORK_PORT"),
|
||||
"127.0.0.1",
|
||||
@@ -6885,7 +6981,9 @@ async function runStart(args: ParsedArgs) {
|
||||
}
|
||||
|
||||
const openworkBaseUrl = `http://127.0.0.1:${openworkPort}`;
|
||||
const openworkConnect = resolveConnectUrl(openworkPort, connectHost);
|
||||
const openworkConnect = remoteAccessEnabled
|
||||
? resolveConnectUrl(openworkPort, connectHost)
|
||||
: {};
|
||||
const openworkConnectUrl = openworkConnect.connectUrl ?? openworkBaseUrl;
|
||||
|
||||
const opencodeBaseUrl =
|
||||
@@ -6895,8 +6993,7 @@ async function runStart(args: ParsedArgs) {
|
||||
const opencodeConnectUrl =
|
||||
sandboxMode !== "none"
|
||||
? `${openworkConnectUrl.replace(/\/$/, "")}/opencode`
|
||||
: (resolveConnectUrl(opencodePort, connectHost).connectUrl ??
|
||||
opencodeBaseUrl);
|
||||
: opencodeBaseUrl;
|
||||
|
||||
const attachCommand =
|
||||
sandboxMode !== "none"
|
||||
@@ -6905,7 +7002,7 @@ async function runStart(args: ParsedArgs) {
|
||||
url: opencodeConnectUrl,
|
||||
workspace: resolvedWorkspace,
|
||||
username: opencodeUsername,
|
||||
password: opencodePassword,
|
||||
password: opencodeCredentials.password,
|
||||
});
|
||||
|
||||
const opencodeRouterHealthUrl = `http://127.0.0.1:${opencodeRouterHealthPort}`;
|
||||
@@ -7042,7 +7139,7 @@ async function runStart(args: ParsedArgs) {
|
||||
headers:
|
||||
opencodeUsername && opencodePassword
|
||||
? {
|
||||
Authorization: `Basic ${encodeBasicAuth(opencodeUsername, opencodePassword)}`,
|
||||
Authorization: `Basic ${encodeBasicAuth(opencodeCredentials.username, opencodeCredentials.password)}`,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
|
||||
@@ -124,7 +124,7 @@ function buildOpenWorkStartCommand(input: ProvisionInput) {
|
||||
shellQuote(input.activityToken),
|
||||
" openwork serve",
|
||||
` --workspace ${shellQuote(env.daytona.runtimeWorkspacePath)}`,
|
||||
` --openwork-host 0.0.0.0`,
|
||||
` --remote-access`,
|
||||
` --openwork-port ${env.daytona.openworkPort}`,
|
||||
` --opencode-host 127.0.0.1`,
|
||||
` --opencode-port ${env.daytona.opencodePort}`,
|
||||
|
||||
@@ -262,7 +262,7 @@ async function provisionWorkerOnRender(
|
||||
].join(" && ");
|
||||
const startCommand = [
|
||||
"mkdir -p /tmp/workspace",
|
||||
"attempt=0; while [ $attempt -lt 3 ]; do attempt=$((attempt + 1)); openwork serve --workspace /tmp/workspace --openwork-host 0.0.0.0 --openwork-port ${PORT:-10000} --opencode-host 127.0.0.1 --opencode-port 4096 --connect-host 127.0.0.1 --cors '*' --approval manual --allow-external --opencode-source external --opencode-bin ./bin/opencode --no-opencode-router --verbose && exit 0; echo \"openwork serve failed (attempt $attempt); retrying in 3s\"; sleep 3; done; exit 1",
|
||||
"attempt=0; while [ $attempt -lt 3 ]; do attempt=$((attempt + 1)); openwork serve --workspace /tmp/workspace --remote-access --openwork-port ${PORT:-10000} --opencode-host 127.0.0.1 --opencode-port 4096 --connect-host 127.0.0.1 --cors '*' --approval manual --allow-external --opencode-source external --opencode-bin ./bin/opencode --no-opencode-router --verbose && exit 0; echo \"openwork serve failed (attempt $attempt); retrying in 3s\"; sleep 3; done; exit 1",
|
||||
].join(" && ");
|
||||
|
||||
const payload = {
|
||||
|
||||
@@ -29,7 +29,7 @@ EXPOSE 3005
|
||||
VOLUME ["/workspace", "/data"]
|
||||
|
||||
# Defaults:
|
||||
# - OpenWork server is public (0.0.0.0:8787)
|
||||
# - OpenWork server is published intentionally via --remote-access
|
||||
# - OpenCode stays internal (127.0.0.1:4096)
|
||||
# - OpenWork server proxies OpenCode via localhost
|
||||
# - OpenCode Router disabled by default
|
||||
@@ -37,7 +37,7 @@ CMD [
|
||||
"openwork",
|
||||
"serve",
|
||||
"--workspace", "/workspace",
|
||||
"--openwork-host", "0.0.0.0",
|
||||
"--remote-access",
|
||||
"--openwork-port", "8787",
|
||||
"--opencode-host", "127.0.0.1",
|
||||
"--opencode-port", "4096",
|
||||
|
||||
@@ -92,7 +92,7 @@ This is a minimal packaging template to run the OpenWork Host contract in a sing
|
||||
It runs:
|
||||
|
||||
- `opencode serve` (engine) bound to `127.0.0.1:4096` inside the container
|
||||
- `openwork-server` bound to `0.0.0.0:8787` (the only published surface)
|
||||
- `openwork-server` published on `0.0.0.0:8787` via an explicit `--remote-access` launch path (the only published surface)
|
||||
|
||||
### Local run (compose)
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
|
||||
exec pnpm --filter openwork-orchestrator dev -- start \
|
||||
--workspace /workspace \
|
||||
--openwork-host 0.0.0.0 \
|
||||
--remote-access \
|
||||
--openwork-port 8787 \
|
||||
--openwork-token "$$OPENWORK_TOKEN" \
|
||||
--openwork-host-token "$$OPENWORK_HOST_TOKEN" \
|
||||
@@ -104,7 +104,6 @@ services:
|
||||
--opencode-router-bin "$$OPENCODE_ROUTER_BIN" \
|
||||
--approval auto \
|
||||
--allow-external \
|
||||
--no-opencode-auth \
|
||||
--cors "*"
|
||||
ports:
|
||||
- "${OPENWORK_PORT:-8787}:8787"
|
||||
|
||||
BIN
pr/security-share-web-runtime-share-modal.png
Normal file
BIN
pr/security-share-web-runtime-share-modal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
@@ -116,7 +116,8 @@ const shutdown = (
|
||||
|
||||
await ensureTmp();
|
||||
|
||||
const host = process.env.OPENWORK_HOST ?? "0.0.0.0";
|
||||
const remoteAccessEnabled = readBool(process.env.OPENWORK_REMOTE_ACCESS);
|
||||
const host = remoteAccessEnabled ? "0.0.0.0" : "127.0.0.1";
|
||||
const viteHost = process.env.VITE_HOST ?? process.env.HOST ?? host;
|
||||
const publicHost = process.env.OPENWORK_PUBLIC_HOST ?? null;
|
||||
const clientHost = publicHost ?? (host === "0.0.0.0" ? "127.0.0.1" : host);
|
||||
@@ -233,6 +234,7 @@ const headlessEnv = {
|
||||
...process.env,
|
||||
OPENWORK_WORKSPACE: workspace,
|
||||
OPENWORK_HOST: host,
|
||||
OPENWORK_REMOTE_ACCESS: remoteAccessEnabled ? "1" : "0",
|
||||
OPENWORK_PORT: String(openworkPort),
|
||||
OPENWORK_TOKEN: openworkToken,
|
||||
OPENWORK_HOST_TOKEN: openworkHostToken,
|
||||
@@ -294,12 +296,10 @@ const headlessProcess = spawnLogged(
|
||||
"--approval",
|
||||
"auto",
|
||||
"--allow-external",
|
||||
"--no-opencode-auth",
|
||||
"--opencode-router",
|
||||
opencodeRouterEnabled ? "true" : "false",
|
||||
...(opencodeRouterRequired ? ["--opencode-router-required"] : []),
|
||||
"--openwork-host",
|
||||
host,
|
||||
...(remoteAccessEnabled ? ["--remote-access"] : []),
|
||||
"--openwork-port",
|
||||
String(openworkPort),
|
||||
"--openwork-token",
|
||||
|
||||
Reference in New Issue
Block a user