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:
Source Open
2026-03-23 17:58:53 -07:00
committed by GitHub
parent 24c47f190b
commit 18723ec767
27 changed files with 469 additions and 142 deletions

View File

@@ -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

View File

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

View File

@@ -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,

View File

@@ -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()}>

View File

@@ -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(),
});

View File

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

View File

@@ -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> {

View File

@@ -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

View File

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

View File

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

View File

@@ -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));

View File

@@ -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));
}

View File

@@ -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),
)
}

View File

@@ -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(),

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)
}

View File

@@ -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");
}

View File

@@ -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>,

View File

@@ -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,
}),

View File

@@ -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}`,

View File

@@ -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 = {

View File

@@ -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",

View File

@@ -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)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

View File

@@ -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",