diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 4baaf2a6..0b135864 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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
diff --git a/README.md b/README.md
index 7ddf39a7..c1df8156 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/STATS.md b/STATS.md
index c5e86b1b..9a9ad399 100644
--- a/STATS.md
+++ b/STATS.md
@@ -64,3 +64,5 @@ Legacy cumulative release-asset totals. For classified v2 buckets, see `STATS_V2
| 2026-03-20 | 183,136 (+928) | 183,136 (+928) |
| 2026-03-21 | 184,156 (+1,020) | 184,156 (+1,020) |
| 2026-03-22 | 184,744 (+588) | 184,744 (+588) |
+| 2026-03-23 | 185,371 (+627) | 185,371 (+627) |
+| 2026-03-24 | 186,649 (+1,278) | 186,649 (+1,278) |
diff --git a/STATS_V2.md b/STATS_V2.md
index e2b92b82..018260ca 100644
--- a/STATS_V2.md
+++ b/STATS_V2.md
@@ -20,3 +20,5 @@ Classified GitHub release asset snapshots. `Manual installs` counts installer do
| 2026-03-20 | 60,221 (+176) | 105,278 (+611) | 17,637 (+141) | 183,136 (+928) |
| 2026-03-21 | 60,558 (+337) | 105,839 (+561) | 17,759 (+122) | 184,156 (+1,020) |
| 2026-03-22 | 60,687 (+129) | 106,219 (+380) | 17,838 (+79) | 184,744 (+588) |
+| 2026-03-23 | 60,848 (+161) | 106,545 (+326) | 17,978 (+140) | 185,371 (+627) |
+| 2026-03-24 | 61,247 (+399) | 107,230 (+685) | 18,172 (+194) | 186,649 (+1,278) |
diff --git a/apps/app/package.json b/apps/app/package.json
index b046d73b..35f6af1c 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -1,7 +1,7 @@
{
"name": "@openwork/app",
"private": true,
- "version": "0.11.182",
+ "version": "0.11.186",
"type": "module",
"scripts": {
"dev": "OPENWORK_DEV_MODE=1 vite",
@@ -18,6 +18,7 @@
"test:events": "node scripts/events.mjs",
"test:todos": "node scripts/todos.mjs",
"test:permissions": "node scripts/permissions.mjs",
+ "test:session-scope": "bun scripts/session-scope.ts",
"test:session-switch": "node scripts/session-switch.mjs",
"test:fs-engine": "node scripts/fs-engine.mjs",
"test:local-file-path": "node scripts/local-file-path.mjs",
diff --git a/apps/app/pr-issue-777-greeting-smoke.png b/apps/app/pr-issue-777-greeting-smoke.png
new file mode 100644
index 00000000..fda4959f
Binary files /dev/null and b/apps/app/pr-issue-777-greeting-smoke.png differ
diff --git a/apps/app/scripts/session-scope.ts b/apps/app/scripts/session-scope.ts
new file mode 100644
index 00000000..74f120b1
--- /dev/null
+++ b/apps/app/scripts/session-scope.ts
@@ -0,0 +1,132 @@
+import assert from "node:assert/strict";
+
+Object.defineProperty(globalThis, "navigator", {
+ configurable: true,
+ value: {
+ platform: "MacIntel",
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)",
+ },
+});
+
+const {
+ resolveScopedClientDirectory,
+ scopedRootsMatch,
+ shouldApplyScopedSessionLoad,
+ shouldRedirectMissingSessionAfterScopedLoad,
+} = await import("../src/app/lib/session-scope.ts");
+
+const starterRoot = "/Users/test/OpenWork/starter";
+const otherRoot = "/Users/test/OpenWork/second";
+
+const results = {
+ ok: true,
+ steps: [] as Array>,
+};
+
+async function step(name: string, fn: () => void | Promise) {
+ results.steps.push({ name, status: "running" });
+ const index = results.steps.length - 1;
+
+ try {
+ await fn();
+ results.steps[index] = { name, status: "ok" };
+ } catch (error) {
+ results.ok = false;
+ results.steps[index] = {
+ name,
+ status: "error",
+ error: error instanceof Error ? error.message : String(error),
+ };
+ throw error;
+ }
+}
+
+try {
+ await step("local connect prefers explicit target root", () => {
+ assert.equal(
+ resolveScopedClientDirectory({ workspaceType: "local", targetRoot: starterRoot }),
+ starterRoot,
+ );
+ assert.equal(
+ resolveScopedClientDirectory({
+ workspaceType: "local",
+ directory: otherRoot,
+ targetRoot: starterRoot,
+ }),
+ otherRoot,
+ );
+ });
+
+ await step("remote connect still waits for remote discovery", () => {
+ assert.equal(resolveScopedClientDirectory({ workspaceType: "remote", targetRoot: starterRoot }), "");
+ });
+
+ await step("scope matching is stable on desktop-style paths", () => {
+ assert.equal(scopedRootsMatch(`${starterRoot}/`, starterRoot.toUpperCase()), true);
+ assert.equal(scopedRootsMatch(starterRoot, otherRoot), false);
+ });
+
+ await step("stale session loads cannot overwrite another workspace sidebar", () => {
+ for (let index = 0; index < 50; index += 1) {
+ assert.equal(
+ shouldApplyScopedSessionLoad({
+ loadedScopeRoot: otherRoot,
+ workspaceRoot: starterRoot,
+ }),
+ false,
+ );
+ }
+ });
+
+ await step("same-scope session loads still update the active workspace", () => {
+ assert.equal(
+ shouldApplyScopedSessionLoad({
+ loadedScopeRoot: `${starterRoot}/`,
+ workspaceRoot: starterRoot,
+ }),
+ true,
+ );
+ });
+
+ await step("route guard only redirects when the loaded scope matches", () => {
+ assert.equal(
+ shouldRedirectMissingSessionAfterScopedLoad({
+ loadedScopeRoot: otherRoot,
+ workspaceRoot: starterRoot,
+ hasMatchingSession: false,
+ }),
+ false,
+ );
+ assert.equal(
+ shouldRedirectMissingSessionAfterScopedLoad({
+ loadedScopeRoot: starterRoot,
+ workspaceRoot: starterRoot,
+ hasMatchingSession: false,
+ }),
+ true,
+ );
+ assert.equal(
+ shouldRedirectMissingSessionAfterScopedLoad({
+ loadedScopeRoot: starterRoot,
+ workspaceRoot: starterRoot,
+ hasMatchingSession: true,
+ }),
+ false,
+ );
+ });
+
+ console.log(JSON.stringify(results, null, 2));
+} catch (error) {
+ results.ok = false;
+ console.error(
+ JSON.stringify(
+ {
+ ...results,
+ error: error instanceof Error ? error.message : String(error),
+ },
+ null,
+ 2,
+ ),
+ );
+ process.exitCode = 1;
+}
diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx
index 114e9f9e..d0b0acd9 100644
--- a/apps/app/src/app/app.tsx
+++ b/apps/app/src/app/app.tsx
@@ -24,6 +24,7 @@ import type {
} from "@opencode-ai/sdk/v2/client";
import { getVersion } from "@tauri-apps/api/app";
+import { homeDir } from "@tauri-apps/api/path";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { parse } from "jsonc-parser";
@@ -57,6 +58,7 @@ import { clearPerfLogs, finishPerf, perfNow, recordPerfLog } from "./lib/perf-lo
import { deepLinkBridgeEvent, drainPendingDeepLinks, type DeepLinkBridgeDetail } from "./lib/deep-link-bridge";
import {
AUTO_COMPACT_CONTEXT_PREF_KEY,
+ CHROME_DEVTOOLS_MCP_ID,
DEFAULT_MODEL,
HIDE_TITLEBAR_PREF_KEY,
MCP_QUICK_CONNECT,
@@ -66,7 +68,12 @@ import {
THINKING_PREF_KEY,
VARIANT_PREF_KEY,
} from "./constants";
-import { parseMcpServersFromContent, removeMcpFromConfig, validateMcpServerName } from "./mcp";
+import {
+ parseMcpServersFromContent,
+ removeMcpFromConfig,
+ usesChromeDevtoolsAutoConnect,
+ validateMcpServerName,
+} from "./mcp";
import {
compareProviders,
mapConfigProvidersToList,
@@ -154,6 +161,10 @@ import {
normalizeModelBehaviorValue,
sanitizeModelBehaviorValue,
} from "./lib/model-behavior";
+import {
+ shouldApplyScopedSessionLoad,
+ shouldRedirectMissingSessionAfterScopedLoad,
+} from "./lib/session-scope";
const fileToDataUrl = (file: File) =>
new Promise((resolve, reject) => {
@@ -934,6 +945,8 @@ export default function App() {
const [clientDirectory, setClientDirectory] = createSignal("");
const [openworkServerSettings, setOpenworkServerSettings] = createSignal({});
+ const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = createSignal(false);
+ const [shareRemoteAccessError, setShareRemoteAccessError] = createSignal(null);
const [openworkServerUrl, setOpenworkServerUrl] = createSignal("");
const [openworkServerStatus, setOpenworkServerStatus] = createSignal("disconnected");
const [openworkServerCapabilities, setOpenworkServerCapabilities] = createSignal(null);
@@ -1490,6 +1503,7 @@ export default function App() {
const {
sessions,
+ loadedScopeRoot: loadedSessionScopeRoot,
sessionById,
sessionStatusById,
selectedSession,
@@ -3322,6 +3336,21 @@ export default function App() {
? activeWorkspace.path
: activeWorkspace?.directory ?? activeWorkspace?.path,
);
+ if (
+ !shouldApplyScopedSessionLoad({
+ loadedScopeRoot: loadedSessionScopeRoot(),
+ workspaceRoot: activeWorkspaceRoot,
+ })
+ ) {
+ if (developerMode()) {
+ console.log("[sidebar-sync] skip stale session scope", {
+ wsId,
+ loadedScopeRoot: loadedSessionScopeRoot(),
+ activeWorkspaceRoot,
+ });
+ }
+ return;
+ }
const scopedSessions = activeWorkspaceRoot
? allSessions.filter((session) => normalizeDirectoryPath(session.directory) === activeWorkspaceRoot)
: allSessions;
@@ -4060,6 +4089,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({});
@@ -5777,10 +5839,14 @@ export default function App() {
const slug = entry.id ?? entry.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
+ const action = mcpServers().some((server) => server.name === slug) ? "updated" : "added";
+
try {
setMcpStatus(null);
setMcpConnectingName(entry.name);
+ let mcpEnvironment: Record | undefined;
+
const mcpEntryConfig: Record = {
type: entryType,
enabled: true,
@@ -5801,6 +5867,18 @@ export default function App() {
throw new Error("Missing MCP command.");
}
mcpEntryConfig["command"] = entry.command;
+
+ if (slug === CHROME_DEVTOOLS_MCP_ID && usesChromeDevtoolsAutoConnect(entry.command) && isTauriRuntime()) {
+ try {
+ const hostHome = (await homeDir()).replace(/[\\/]+$/, "");
+ if (hostHome) {
+ mcpEnvironment = { HOME: hostHome };
+ mcpEntryConfig["environment"] = mcpEnvironment;
+ }
+ } catch {
+ // ignore and let the MCP use the default worker environment
+ }
+ }
}
if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) {
@@ -5853,6 +5931,7 @@ export default function App() {
type: "local" as const,
command: entry.command!,
enabled: true,
+ ...(mcpEnvironment ? { environment: mcpEnvironment } : {}),
};
const status = unwrap(
@@ -5864,6 +5943,7 @@ export default function App() {
);
setMcpStatuses(status as McpStatusMap);
+ markReloadRequired("mcp", { type: "mcp", name: slug, action });
await refreshMcpServers();
if (entry.oauth) {
@@ -6039,6 +6119,7 @@ export default function App() {
await removeMcpFromConfig(projectDir, name);
}
+ markReloadRequired("mcp", { type: "mcp", name, action: "removed" });
await refreshMcpServers();
if (selectedMcp() === name) {
setSelectedMcp(null);
@@ -7243,6 +7324,9 @@ export default function App() {
reconnectOpenworkServer,
openworkServerSettings: openworkServerSettings(),
openworkServerHostInfo: openworkServerHostInfo(),
+ shareRemoteAccessBusy: shareRemoteAccessBusy(),
+ shareRemoteAccessError: shareRemoteAccessError(),
+ saveShareRemoteAccess,
openworkServerCapabilities: devtoolsCapabilities(),
openworkServerDiagnostics: openworkServerDiagnostics(),
openworkServerWorkspaceId: openworkServerWorkspaceId(),
@@ -7459,8 +7543,10 @@ export default function App() {
logoutMcpAuth,
removeMcp,
refreshMcpServers,
- showMcpReloadBanner: false,
- mcpReloadBlocked: anyActiveRuns(),
+ showMcpReloadBanner:
+ reloadRequired() && (reloadTrigger()?.type === "mcp" || reloadTrigger()?.type === "config"),
+ mcpReloadBlocked: activeReloadBlockingSessions().length > 0,
+ reloadBlocked: activeReloadBlockingSessions().length > 0,
reloadMcpEngine: () => reloadWorkspaceEngineAndResume(),
language: currentLocale(),
setLanguage: setLocale,
@@ -7524,6 +7610,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,
@@ -7705,7 +7794,14 @@ export default function App() {
// If the URL points at a session that no longer exists (e.g. after deletion),
// route back to /session so the app can fall back safely.
- if (sessionsLoaded() && !sessions().some((session) => session.id === id)) {
+ if (
+ sessionsLoaded() &&
+ shouldRedirectMissingSessionAfterScopedLoad({
+ loadedScopeRoot: loadedSessionScopeRoot(),
+ workspaceRoot: workspaceStore.activeWorkspaceRoot().trim(),
+ hasMatchingSession: sessions().some((session) => session.id === id),
+ })
+ ) {
if (selectedSessionId() === id) {
setSelectedSessionId(null);
}
diff --git a/apps/app/src/app/components/confirm-modal.tsx b/apps/app/src/app/components/confirm-modal.tsx
index de73c98a..c1c3ff07 100644
--- a/apps/app/src/app/components/confirm-modal.tsx
+++ b/apps/app/src/app/components/confirm-modal.tsx
@@ -11,6 +11,8 @@ export type ConfirmModalProps = {
confirmLabel: string;
cancelLabel: string;
variant?: "danger" | "warning";
+ confirmButtonVariant?: "primary" | "secondary" | "ghost" | "outline" | "danger";
+ cancelButtonVariant?: "primary" | "secondary" | "ghost" | "outline" | "danger";
onConfirm: () => void;
onCancel: () => void;
};
@@ -40,11 +42,11 @@ export default function ConfirmModal(props: ConfirmModalProps) {
-
+
{props.cancelLabel}
{props.confirmLabel}
diff --git a/apps/app/src/app/components/control-chrome-setup-modal.tsx b/apps/app/src/app/components/control-chrome-setup-modal.tsx
new file mode 100644
index 00000000..d8674014
--- /dev/null
+++ b/apps/app/src/app/components/control-chrome-setup-modal.tsx
@@ -0,0 +1,150 @@
+import { Show, createEffect, createSignal } from "solid-js";
+import { Check, ExternalLink, Loader2, MonitorSmartphone, Settings2, X } from "lucide-solid";
+import Button from "./button";
+import { t, type Language } from "../../i18n";
+
+export type ControlChromeSetupModalProps = {
+ open: boolean;
+ busy: boolean;
+ language: Language;
+ mode: "connect" | "edit";
+ initialUseExistingProfile: boolean;
+ onClose: () => void;
+ onSave: (useExistingProfile: boolean) => void;
+};
+
+export default function ControlChromeSetupModal(props: ControlChromeSetupModalProps) {
+ const tr = (key: string) => t(key, props.language);
+ const [useExistingProfile, setUseExistingProfile] = createSignal(props.initialUseExistingProfile);
+
+ createEffect(() => {
+ if (!props.open) return;
+ setUseExistingProfile(props.initialUseExistingProfile);
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Chrome DevTools MCP
+
+
+
+ {tr("mcp.control_chrome_setup_title")}
+
+
+ {tr("mcp.control_chrome_setup_subtitle")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {tr("mcp.control_chrome_browser_title")}
+
+
+ {tr("mcp.control_chrome_browser_hint")}
+
+
+ 1. {tr("mcp.control_chrome_browser_step_one")}
+ 2. {tr("mcp.control_chrome_browser_step_two")}
+ 3. {tr("mcp.control_chrome_browser_step_three")}
+
+
+ {tr("mcp.control_chrome_docs")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {tr("mcp.control_chrome_profile_title")}
+
+
+ {tr("mcp.control_chrome_profile_hint")}
+
+
+
setUseExistingProfile((current) => !current)}
+ class="mt-4 flex w-full items-center justify-between gap-4 rounded-2xl border border-gray-6 bg-gray-2 px-4 py-4 text-left transition-colors hover:bg-gray-3"
+ >
+
+
+ {tr("mcp.control_chrome_toggle_label")}
+
+
+ {tr("mcp.control_chrome_toggle_hint")}
+
+
+
+
+
+
+
+ {useExistingProfile()
+ ? tr("mcp.control_chrome_toggle_on")
+ : tr("mcp.control_chrome_toggle_off")}
+
+
+
+
+
+
+
+
+ {tr("mcp.auth.cancel")}
+
+ props.onSave(useExistingProfile())} disabled={props.busy}>
+
+ <>
+
+ {props.mode === "edit" ? tr("mcp.control_chrome_save") : tr("mcp.control_chrome_connect")}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/apps/app/src/app/components/share-workspace-modal.tsx b/apps/app/src/app/components/share-workspace-modal.tsx
index 2b802f52..cce48722 100644
--- a/apps/app/src/app/components/share-workspace-modal.tsx
+++ b/apps/app/src/app/components/share-workspace-modal.tsx
@@ -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;
+ };
note?: string | null;
publisherBaseUrl?: string;
onShareWorkspaceProfile?: () => void;
@@ -62,6 +68,7 @@ export default function ShareWorkspaceModal(props: {
const [revealedByIndex, setRevealedByIndex] = createSignal>({});
const [copiedKey, setCopiedKey] = createSignal(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: {
⚠️
- 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.
+
+
+
+ {(remoteAccess) => {
+ const hasPendingChange = () =>
+ remoteAccessEnabled() !== remoteAccess().enabled;
+ return (
+
+
+
+
Remote access
+
+ Off by default. Turn this on only when you want this worker reachable from another machine.
+
+
+
+
+ setRemoteAccessEnabled(event.currentTarget.checked)}
+ disabled={remoteAccess().busy}
+ />
+
+
+
+
+
+
+ {remoteAccess().enabled
+ ? "Remote access is currently enabled."
+ : "Remote access is currently disabled."}
+
+
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"}
+
+
+
+
+
+ {remoteAccess().error}
+
+
+
+ );
+ }}
+
+
@@ -351,9 +436,15 @@ export default function ShareWorkspaceModal(props: {
+
0} fallback={
+
+ Enable remote access and click Save to restart the worker and reveal the live connection details for this workspace.
+
+ }>
{(field, index) => renderCredentialField(field, index, "primary")}
+
diff --git a/apps/app/src/app/constants.ts b/apps/app/src/app/constants.ts
index 8c929285..5b01d3ac 100644
--- a/apps/app/src/app/constants.ts
+++ b/apps/app/src/app/constants.ts
@@ -33,6 +33,9 @@ export type McpDirectoryInfo = {
oauth: boolean;
};
+export const CHROME_DEVTOOLS_MCP_ID = "chrome-devtools";
+export const CHROME_DEVTOOLS_MCP_COMMAND = ["npx", "-y", "chrome-devtools-mcp@latest"] as const;
+
export const MCP_QUICK_CONNECT: McpDirectoryInfo[] = [
{
name: "Notion",
@@ -70,11 +73,11 @@ export const MCP_QUICK_CONNECT: McpDirectoryInfo[] = [
oauth: false,
},
{
- id: "chrome-devtools",
+ id: CHROME_DEVTOOLS_MCP_ID,
name: "Control Chrome",
description: "Drive Chrome tabs with browser automation.",
type: "local",
- command: ["npx", "-y", "chrome-devtools-mcp@latest"],
+ command: [...CHROME_DEVTOOLS_MCP_COMMAND],
oauth: false,
},
];
diff --git a/apps/app/src/app/context/session.ts b/apps/app/src/app/context/session.ts
index f0a48acf..bf53764a 100644
--- a/apps/app/src/app/context/session.ts
+++ b/apps/app/src/app/context/session.ts
@@ -27,6 +27,7 @@ import {
safeStringify,
} from "../utils";
import { unwrap } from "../lib/opencode";
+import { abortSessionSafe } from "../lib/opencode-session";
import { finishPerf, perfNow, recordPerfLog } from "../lib/perf-log";
import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX } from "../types";
@@ -67,9 +68,13 @@ const sortSessionsByActivity = (list: Session[]) =>
const SYNTHETIC_CONTINUE_CONTROL_PATTERN =
/^\s*continue if you have next steps,\s*or stop and ask for clarification if you are unsure how to proceed\.?\s*$/i;
+const SYNTHETIC_TASK_SUMMARY_CONTROL_PATTERN =
+ /^\s*summarize the task tool output above and continue with your task\.?\s*$/i;
const COMPACTION_DIAGNOSTIC_WINDOW_MS = 60_000;
const COMPACTION_LOOP_WARN_THRESHOLD = 3;
const COMPACTION_LOOP_WARN_MIN_INTERVAL_MS = 10_000;
+const SYNTHETIC_TASK_SUMMARY_LOOP_ABORT_THRESHOLD = 5;
+const SYNTHETIC_CONTROL_LOOP_ABORT_MIN_INTERVAL_MS = 30_000;
const INITIAL_SESSION_MESSAGE_LIMIT = 140;
const SESSION_MESSAGE_LOAD_CHUNK = 120;
@@ -198,10 +203,13 @@ export function createSessionStore(options: {
const [messageLimitBySession, setMessageLimitBySession] = createSignal>({});
const [messageCompleteBySession, setMessageCompleteBySession] = createSignal>({});
const [messageLoadBusyBySession, setMessageLoadBusyBySession] = createSignal>({});
+ const [loadedScopeRoot, setLoadedScopeRoot] = createSignal("");
const reloadDetectionSet = new Set();
const invalidToolDetectionSet = new Set();
const syntheticContinueEventTimesBySession = new Map();
+ const syntheticTaskSummaryEventTimesBySession = new Map();
const syntheticContinueLoopLastWarnAtBySession = new Map();
+ const syntheticLoopLastAbortAtByKey = new Map();
const skillPathPattern = /[\\/]\.opencode[\\/](skill|skills)[\\/]/i;
const skillNamePattern = /[\\/]\.opencode[\\/](?:skill|skills)[\\/]+([^\\/]+)/i;
@@ -423,6 +431,16 @@ export function createSessionStore(options: {
return SYNTHETIC_CONTINUE_CONTROL_PATTERN.test(text);
};
+ const isSyntheticTaskSummaryControlPart = (part: Part) => {
+ if (part.type !== "text") return false;
+ const record = part as Part & { text?: unknown; synthetic?: unknown; ignored?: unknown };
+ if (record.synthetic !== true) return false;
+ if (record.ignored === true) return false;
+ const text = typeof record.text === "string" ? record.text.trim() : "";
+ if (!text) return false;
+ return SYNTHETIC_TASK_SUMMARY_CONTROL_PATTERN.test(text);
+ };
+
const recordSyntheticContinueDiagnostic = (part: Part) => {
if (!isSyntheticContinueControlPart(part)) return;
const sessionID = part.sessionID;
@@ -459,6 +477,25 @@ export function createSessionStore(options: {
});
};
+ const recordSyntheticTaskSummaryDiagnostic = (part: Part) => {
+ if (!isSyntheticTaskSummaryControlPart(part)) return;
+ const sessionID = part.sessionID;
+ const now = Date.now();
+ const windowStart = now - COMPACTION_DIAGNOSTIC_WINDOW_MS;
+ const previous = syntheticTaskSummaryEventTimesBySession.get(sessionID) ?? [];
+ const next = previous.filter((timestamp) => timestamp >= windowStart);
+ next.push(now);
+ syntheticTaskSummaryEventTimesBySession.set(sessionID, next);
+
+ recordPerfLog(sessionDebugEnabled(), "session.task", "synthetic-task-summary-control", {
+ sessionID,
+ messageID: part.messageID,
+ partID: part.id,
+ countPerMinute: next.length,
+ windowMs: COMPACTION_DIAGNOSTIC_WINDOW_MS,
+ });
+ };
+
const addError = (error: unknown, fallback = "Unknown error") => {
const message = error instanceof Error ? error.message : fallback;
if (!message) return;
@@ -489,6 +526,66 @@ export function createSessionStore(options: {
});
};
+ const maybeAbortSyntheticControlLoop = (part: Part) => {
+ const sessionID = part.sessionID;
+ if (!sessionID) return;
+
+ const kind = isSyntheticTaskSummaryControlPart(part)
+ ? "task-summary"
+ : isSyntheticContinueControlPart(part)
+ ? "compaction-continue"
+ : null;
+ if (!kind) return;
+
+ const events =
+ kind === "task-summary"
+ ? syntheticTaskSummaryEventTimesBySession.get(sessionID) ?? []
+ : syntheticContinueEventTimesBySession.get(sessionID) ?? [];
+ const threshold =
+ kind === "task-summary"
+ ? SYNTHETIC_TASK_SUMMARY_LOOP_ABORT_THRESHOLD
+ : COMPACTION_LOOP_WARN_THRESHOLD;
+ if (events.length < threshold) return;
+
+ const key = `${kind}:${sessionID}`;
+ const now = Date.now();
+ const lastAbortAt = syntheticLoopLastAbortAtByKey.get(key) ?? 0;
+ if (now - lastAbortAt < SYNTHETIC_CONTROL_LOOP_ABORT_MIN_INTERVAL_MS) return;
+ syntheticLoopLastAbortAtByKey.set(key, now);
+
+ const message =
+ kind === "task-summary"
+ ? "OpenWork stopped this run after detecting a likely synthetic task-summary loop. The engine kept asking itself to summarize task output and continue, which can repeat Goal/Instructions/Discoveries summaries without making progress."
+ : "OpenWork stopped this run after detecting a likely auto-compaction continuation loop. The engine kept injecting synthetic continue prompts after compaction, which can burn tokens without advancing the task.";
+
+ sessionWarn("session.synthetic-loop.abort", {
+ sessionID,
+ kind,
+ countPerMinute: events.length,
+ });
+ recordPerfLog(sessionDebugEnabled(), "session.loop", "abort-suspected-synthetic-loop", {
+ sessionID,
+ kind,
+ countPerMinute: events.length,
+ threshold,
+ windowMs: COMPACTION_DIAGNOSTIC_WINDOW_MS,
+ });
+
+ const c = options.client();
+ if (!c) {
+ appendSessionErrorTurn(sessionID, message);
+ options.setError(message);
+ setStore("sessionStatus", sessionID, "idle");
+ return;
+ }
+
+ void abortSessionSafe(c, sessionID).finally(() => {
+ appendSessionErrorTurn(sessionID, message);
+ options.setError(message);
+ setStore("sessionStatus", sessionID, "idle");
+ });
+ };
+
const truncateErrorField = (value: unknown, max = 500) => {
if (typeof value !== "string") return null;
const text = value.trim();
@@ -723,6 +820,7 @@ export function createSessionStore(options: {
})),
});
sessionDebug("sessions:load:filtered", { root: root || null, count: filtered.length });
+ setLoadedScopeRoot(root);
rememberSessions(filtered);
setStore("sessions", reconcile(sortSessionsByActivity(filtered), { key: "id" }));
}
@@ -1171,7 +1269,10 @@ export function createSessionStore(options: {
const info = record.info as Session | undefined;
if (info?.id) {
syntheticContinueEventTimesBySession.delete(info.id);
+ syntheticTaskSummaryEventTimesBySession.delete(info.id);
syntheticContinueLoopLastWarnAtBySession.delete(info.id);
+ syntheticLoopLastAbortAtByKey.delete(`task-summary:${info.id}`);
+ syntheticLoopLastAbortAtByKey.delete(`compaction-continue:${info.id}`);
setStore(
produce((draft: StoreState) => {
delete draft.sessionInfoById[info.id];
@@ -1332,6 +1433,8 @@ export function createSessionStore(options: {
store.parts[part.messageID]?.find((item) => item.id === part.id) ??
part;
recordSyntheticContinueDiagnostic(resolvedPart);
+ recordSyntheticTaskSummaryDiagnostic(resolvedPart);
+ maybeAbortSyntheticControlLoop(resolvedPart);
const partUpdatedMs = Math.round((perfNow() - partUpdatedStartedAt) * 100) / 100;
if (sessionDebugEnabled() && (partUpdatedMs >= 8 || (delta?.length ?? 0) >= 120)) {
const textLength =
@@ -1626,6 +1729,7 @@ export function createSessionStore(options: {
return {
sessions,
+ loadedScopeRoot,
sessionById,
sessionErrorTurnsById: (sessionID: string | null) => (sessionID ? store.sessionErrorTurns[sessionID] ?? [] : []),
selectedSessionErrorTurns: createMemo(() => {
diff --git a/apps/app/src/app/context/workspace.ts b/apps/app/src/app/context/workspace.ts
index 670a3903..d212f598 100644
--- a/apps/app/src/app/context/workspace.ts
+++ b/apps/app/src/app/context/workspace.ts
@@ -21,6 +21,7 @@ import {
writeStartupPreference,
} from "../utils";
import { unwrap } from "../lib/opencode";
+import { resolveScopedClientDirectory } from "../lib/session-scope";
import {
buildOpenworkWorkspaceBaseUrl,
createOpenworkServerClient,
@@ -762,12 +763,13 @@ export function createWorkspaceStore(options: {
) {
const now = Date.now();
if (now - lastEngineReconnectAt > 10_000) {
+ const reconnectRoot = activeWorkspacePath().trim() || info.projectDir?.trim() || "";
lastEngineReconnectAt = now;
reconnectingEngine = true;
connectToServer(
info.baseUrl,
- info.projectDir ?? undefined,
- { reason: "engine-refresh" },
+ reconnectRoot || undefined,
+ { workspaceType: "local", targetRoot: reconnectRoot, reason: "engine-refresh" },
auth ?? undefined,
{ quiet: true, navigate: false },
)
@@ -1174,8 +1176,8 @@ export function createWorkspaceStore(options: {
if (nextInfo.baseUrl) {
connectedToLocalHost = await connectToServer(
nextInfo.baseUrl,
- nextInfo.projectDir ?? undefined,
- { reason: "workspace-attach-local" },
+ next.path,
+ { workspaceType: "local", targetRoot: next.path, reason: "workspace-attach-local" },
auth,
{ navigate: false },
);
@@ -1232,8 +1234,8 @@ export function createWorkspaceStore(options: {
if (newInfo.baseUrl) {
const ok = await connectToServer(
newInfo.baseUrl,
- newInfo.projectDir ?? undefined,
- { reason: "workspace-orchestrator-switch" },
+ next.path,
+ { workspaceType: "local", targetRoot: next.path, reason: "workspace-orchestrator-switch" },
auth,
{ navigate: false },
);
@@ -1252,6 +1254,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(),
});
@@ -1266,8 +1269,8 @@ export function createWorkspaceStore(options: {
if (newInfo.baseUrl) {
const ok = await connectToServer(
newInfo.baseUrl,
- newInfo.projectDir ?? undefined,
- { reason: "workspace-restart" },
+ next.path,
+ { workspaceType: "local", targetRoot: next.path, reason: "workspace-restart" },
auth,
{ navigate: false },
);
@@ -1362,7 +1365,11 @@ export function createWorkspaceStore(options: {
const connectMetrics: NonNullable = {};
try {
- let resolvedDirectory = directory?.trim() ?? "";
+ let resolvedDirectory = resolveScopedClientDirectory({
+ directory,
+ targetRoot: context?.targetRoot,
+ workspaceType: context?.workspaceType ?? "local",
+ });
let nextClient = createClient(nextBaseUrl, resolvedDirectory || undefined, auth);
const healthTimeoutMs = resolveConnectHealthTimeoutMs(context?.reason);
const health = await waitForHealthy(nextClient, { timeoutMs: healthTimeoutMs });
@@ -2843,6 +2850,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(),
});
@@ -2853,16 +2861,16 @@ export function createWorkspaceStore(options: {
const auth = username && password ? { username, password } : undefined;
setEngineAuth(auth ?? null);
- if (info.baseUrl) {
- const ok = await connectToServer(
- info.baseUrl,
- info.projectDir ?? undefined,
- { reason: "host-start" },
- auth,
- { navigate: optionsOverride?.navigate ?? true },
- );
- if (!ok) return false;
- }
+ if (info.baseUrl) {
+ const ok = await connectToServer(
+ info.baseUrl,
+ dir,
+ { workspaceType: "local", targetRoot: dir, reason: "host-start" },
+ auth,
+ { navigate: optionsOverride?.navigate ?? true },
+ );
+ if (!ok) return false;
+ }
markOnboardingComplete();
return true;
@@ -3020,8 +3028,8 @@ export function createWorkspaceStore(options: {
if (nextInfo.baseUrl) {
const ok = await connectToServer(
nextInfo.baseUrl,
- nextInfo.projectDir ?? undefined,
- { reason: "engine-reload-orchestrator" },
+ root,
+ { workspaceType: "local", targetRoot: root, reason: "engine-reload-orchestrator" },
auth,
);
if (!ok) {
@@ -3041,6 +3049,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(),
});
@@ -3054,8 +3063,8 @@ export function createWorkspaceStore(options: {
if (nextInfo.baseUrl) {
const ok = await connectToServer(
nextInfo.baseUrl,
- nextInfo.projectDir ?? undefined,
- { reason: "engine-reload" },
+ root,
+ { workspaceType: "local", targetRoot: root, reason: "engine-reload" },
auth,
);
if (!ok) {
@@ -3340,11 +3349,12 @@ export function createWorkspaceStore(options: {
options.setStartupPreference("local");
if (info?.running && info.baseUrl) {
+ const bootstrapRoot = activeWorkspacePath().trim() || info.projectDir?.trim() || "";
options.setOnboardingStep("connecting");
const ok = await connectToServer(
info.baseUrl,
- info.projectDir ?? undefined,
- { reason: "bootstrap-local" },
+ bootstrapRoot || undefined,
+ { workspaceType: "local", targetRoot: bootstrapRoot, reason: "bootstrap-local" },
engineAuth() ?? undefined,
);
if (!ok) {
@@ -3414,10 +3424,11 @@ export function createWorkspaceStore(options: {
async function onAttachHost() {
options.setStartupPreference("local");
options.setOnboardingStep("connecting");
+ const attachRoot = activeWorkspacePath().trim() || engine()?.projectDir?.trim() || "";
const ok = await connectToServer(
engine()?.baseUrl ?? "",
- engine()?.projectDir ?? undefined,
- { reason: "attach-local" },
+ attachRoot || undefined,
+ { workspaceType: "local", targetRoot: attachRoot, reason: "attach-local" },
engineAuth() ?? undefined,
);
if (!ok) {
diff --git a/apps/app/src/app/lib/opencode.ts b/apps/app/src/app/lib/opencode.ts
index b46d202e..d9105490 100644
--- a/apps/app/src/app/lib/opencode.ts
+++ b/apps/app/src/app/lib/opencode.ts
@@ -7,6 +7,33 @@ type FieldsResult =
| ({ data: T; error?: undefined } & { request: Request; response: Response })
| ({ data?: undefined; error: unknown } & { request: Request; response: Response });
+type PromptAsyncParameters = {
+ sessionID: string;
+ directory?: string;
+ messageID?: string;
+ model?: { providerID: string; modelID: string };
+ agent?: string;
+ noReply?: boolean;
+ tools?: { [key: string]: boolean };
+ system?: string;
+ variant?: string;
+ parts?: unknown[];
+ reasoning_effort?: string;
+};
+
+type CommandParameters = {
+ sessionID: string;
+ directory?: string;
+ messageID?: string;
+ agent?: string;
+ model?: string;
+ arguments?: string;
+ command?: string;
+ variant?: string;
+ parts?: unknown[];
+ reasoning_effort?: string;
+};
+
export type OpencodeAuth = {
username?: string;
password?: string;
@@ -36,6 +63,55 @@ function resolveRequestTimeoutMs(input: RequestInfo | URL, fallbackMs: number):
return fallbackMs;
}
+
+function buildDirectoryHeader(directory?: string) {
+ if (!directory?.trim()) return undefined;
+ const trimmed = directory.trim();
+ return /[^\x00-\x7F]/.test(trimmed) ? encodeURIComponent(trimmed) : trimmed;
+}
+
+async function postSessionRequest(
+ fetchImpl: typeof globalThis.fetch,
+ baseUrl: string,
+ path: string,
+ body: Record,
+ options?: { headers?: Record; directory?: string; throwOnError?: boolean },
+): Promise> {
+ const headers = new Headers(options?.headers);
+ headers.set("Content-Type", "application/json");
+ const directoryHeader = buildDirectoryHeader(options?.directory);
+ if (directoryHeader) {
+ headers.set("x-opencode-directory", directoryHeader);
+ }
+
+ const response = await fetchImpl(`${baseUrl}${path}`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ });
+
+ const request = new Request(`${baseUrl}${path}`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ });
+
+ if (response.ok) {
+ const data = response.status === 204 ? ({} as T) : ((await response.json()) as T);
+ return { data, request, response };
+ }
+
+ const text = await response.text();
+ let error: unknown = text;
+ try {
+ error = text ? JSON.parse(text) : text;
+ } catch {
+ // ignore
+ }
+ if (options?.throwOnError) throw error;
+ return { error, request, response };
+}
+
async function fetchWithTimeout(
fetchImpl: typeof globalThis.fetch,
input: RequestInfo | URL,
@@ -153,12 +229,46 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod
? createTauriFetch(auth)
: (input: RequestInfo | URL, init?: RequestInit) =>
fetchWithTimeout(globalThis.fetch, input, init, DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS);
- return createOpencodeClient({
+ const client = createOpencodeClient({
baseUrl,
directory,
headers: Object.keys(headers).length ? headers : undefined,
fetch: fetchImpl,
});
+
+ const session = client.session as typeof client.session;
+ const sessionOverrides = session as any as {
+ promptAsync: (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => Promise>;
+ command: (parameters: CommandParameters, options?: { throwOnError?: boolean }) => Promise>;
+ };
+
+ const promptAsyncOriginal = sessionOverrides.promptAsync.bind(session);
+ sessionOverrides.promptAsync = (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => {
+ if (!("reasoning_effort" in parameters)) {
+ return promptAsyncOriginal(parameters, options);
+ }
+ const { sessionID, directory: requestDirectory, ...body } = parameters;
+ return postSessionRequest(fetchImpl, baseUrl, `/session/${encodeURIComponent(sessionID)}/prompt_async`, body, {
+ headers: Object.keys(headers).length ? headers : undefined,
+ directory: requestDirectory ?? directory,
+ throwOnError: options?.throwOnError,
+ });
+ };
+
+ const commandOriginal = sessionOverrides.command.bind(session);
+ sessionOverrides.command = (parameters: CommandParameters, options?: { throwOnError?: boolean }) => {
+ if (!("reasoning_effort" in parameters)) {
+ return commandOriginal(parameters, options);
+ }
+ const { sessionID, directory: requestDirectory, ...body } = parameters;
+ return postSessionRequest(fetchImpl, baseUrl, `/session/${encodeURIComponent(sessionID)}/command`, body, {
+ headers: Object.keys(headers).length ? headers : undefined,
+ directory: requestDirectory ?? directory,
+ throwOnError: options?.throwOnError,
+ });
+ };
+
+ return client;
}
export async function waitForHealthy(
diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts
index 7c51dd0e..b02d79ca 100644
--- a/apps/app/src/app/lib/openwork-server.ts
+++ b/apps/app/src/app/lib/openwork-server.ts
@@ -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
}
diff --git a/apps/app/src/app/lib/session-scope.ts b/apps/app/src/app/lib/session-scope.ts
new file mode 100644
index 00000000..acf50c89
--- /dev/null
+++ b/apps/app/src/app/lib/session-scope.ts
@@ -0,0 +1,45 @@
+import { normalizeDirectoryPath } from "../utils";
+
+type WorkspaceType = "local" | "remote";
+
+export function resolveScopedClientDirectory(input: {
+ directory?: string | null;
+ targetRoot?: string | null;
+ workspaceType?: WorkspaceType | null;
+}) {
+ const directory = input.directory?.trim() ?? "";
+ if (directory) return directory;
+
+ if (input.workspaceType === "remote") return "";
+
+ return input.targetRoot?.trim() ?? "";
+}
+
+export function scopedRootsMatch(a?: string | null, b?: string | null) {
+ const left = normalizeDirectoryPath(a ?? "");
+ const right = normalizeDirectoryPath(b ?? "");
+ if (!left || !right) return false;
+ return left === right;
+}
+
+export function shouldApplyScopedSessionLoad(input: {
+ loadedScopeRoot?: string | null;
+ workspaceRoot?: string | null;
+}) {
+ const workspaceRoot = normalizeDirectoryPath(input.workspaceRoot ?? "");
+ if (!workspaceRoot) return true;
+ return scopedRootsMatch(input.loadedScopeRoot, workspaceRoot);
+}
+
+export function shouldRedirectMissingSessionAfterScopedLoad(input: {
+ loadedScopeRoot?: string | null;
+ workspaceRoot?: string | null;
+ hasMatchingSession: boolean;
+}) {
+ if (input.hasMatchingSession) return false;
+
+ const workspaceRoot = normalizeDirectoryPath(input.workspaceRoot ?? "");
+ if (!workspaceRoot) return false;
+
+ return scopedRootsMatch(input.loadedScopeRoot, workspaceRoot);
+}
diff --git a/apps/app/src/app/lib/tauri.ts b/apps/app/src/app/lib/tauri.ts
index 85e5e0d2..07073566 100644
--- a/apps/app/src/app/lib/tauri.ts
+++ b/apps/app/src/app/lib/tauri.ts
@@ -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 {
return invoke("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 {
export async function engineRestart(options?: {
opencodeEnableExa?: boolean;
+ openworkRemoteAccess?: boolean;
}): Promise {
return invoke("engine_restart", {
opencodeEnableExa: options?.opencodeEnableExa ?? null,
+ openworkRemoteAccess: options?.openworkRemoteAccess ?? null,
});
}
@@ -514,8 +519,12 @@ export async function openworkServerInfo(): Promise {
return invoke("openwork_server_info");
}
-export async function openworkServerRestart(): Promise {
- return invoke("openwork_server_restart");
+export async function openworkServerRestart(options?: {
+ remoteAccessEnabled?: boolean;
+}): Promise {
+ return invoke("openwork_server_restart", {
+ remoteAccessEnabled: options?.remoteAccessEnabled ?? null,
+ });
}
export async function engineInfo(): Promise {
diff --git a/apps/app/src/app/mcp.ts b/apps/app/src/app/mcp.ts
index b6d6fc1c..41f37781 100644
--- a/apps/app/src/app/mcp.ts
+++ b/apps/app/src/app/mcp.ts
@@ -1,9 +1,42 @@
import { parse } from "jsonc-parser";
import type { McpServerConfig, McpServerEntry } from "./types";
import { readOpencodeConfig, writeOpencodeConfig } from "./lib/tauri";
+import { CHROME_DEVTOOLS_MCP_COMMAND, CHROME_DEVTOOLS_MCP_ID } from "./constants";
type McpConfigValue = Record | null | undefined;
+export const CHROME_DEVTOOLS_AUTO_CONNECT_ARG = "--autoConnect";
+
+type McpIdentity = {
+ id?: string;
+ name: string;
+};
+
+export function normalizeMcpSlug(name: string): string {
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
+}
+
+export function getMcpIdentityKey(entry: McpIdentity): string {
+ return entry.id ?? normalizeMcpSlug(entry.name);
+}
+
+export function isChromeDevtoolsMcp(entry: McpIdentity | string | null | undefined): boolean {
+ if (!entry) return false;
+ const key = typeof entry === "string" ? entry : getMcpIdentityKey(entry);
+ return key === CHROME_DEVTOOLS_MCP_ID || normalizeMcpSlug(typeof entry === "string" ? entry : entry.name) === "control-chrome";
+}
+
+export function usesChromeDevtoolsAutoConnect(command?: string[]): boolean {
+ return Array.isArray(command) && command.includes(CHROME_DEVTOOLS_AUTO_CONNECT_ARG);
+}
+
+export function buildChromeDevtoolsCommand(command: string[] | undefined, useExistingProfile: boolean): string[] {
+ const base = Array.isArray(command) && command.length
+ ? command.filter((part) => part !== CHROME_DEVTOOLS_AUTO_CONNECT_ARG)
+ : [...CHROME_DEVTOOLS_MCP_COMMAND];
+ return useExistingProfile ? [...base, CHROME_DEVTOOLS_AUTO_CONNECT_ARG] : base;
+}
+
export function validateMcpServerName(name: string): string {
const trimmed = name.trim();
if (!trimmed) {
diff --git a/apps/app/src/app/pages/config.tsx b/apps/app/src/app/pages/config.tsx
index e769cc6f..c0e0374a 100644
--- a/apps/app/src/app/pages/config.tsx
+++ b/apps/app/src/app/pages/config.tsx
@@ -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) {
{hostConnectUrl() || "Starting server…"}
- {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."}
@@ -368,7 +374,11 @@ export default function ConfigView(props: ConfigViewProps) {
? "••••••••••••"
: "—"}
-
Routine remote access for phones or laptops connecting to this server.
+
+ {hostRemoteAccessEnabled()
+ ? "Routine remote access for phones or laptops connecting to this server."
+ : "Stored in advance for remote sharing, but remote access is currently disabled."}
+
- Use this when a remote client needs to answer permission prompts or take owner-only actions.
+
+ {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."}
+
Promise;
openworkServerSettings: OpenworkServerSettings;
openworkServerHostInfo: OpenworkServerInfo | null;
+ shareRemoteAccessBusy: boolean;
+ shareRemoteAccessError: string | null;
+ saveShareRemoteAccess: (enabled: boolean) => Promise;
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() ||
@@ -1303,6 +1309,7 @@ export default function DashboardView(props: DashboardViewProps) {
openworkServerClient={props.openworkServerClient}
openworkReconnectBusy={props.openworkReconnectBusy}
reconnectOpenworkServer={props.reconnectOpenworkServer}
+ restartLocalServer={props.restartLocalServer}
openworkServerWorkspaceId={props.openworkServerWorkspaceId}
activeWorkspaceRoot={props.activeWorkspaceRoot}
developerMode={props.developerMode}
@@ -1354,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}
@@ -1511,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}
diff --git a/apps/app/src/app/pages/identities.tsx b/apps/app/src/app/pages/identities.tsx
index 2d68cfa6..e172998a 100644
--- a/apps/app/src/app/pages/identities.tsx
+++ b/apps/app/src/app/pages/identities.tsx
@@ -10,6 +10,7 @@ import {
} from "lucide-solid";
import Button from "../components/button";
+import ConfirmModal from "../components/confirm-modal";
import {
buildOpenworkWorkspaceBaseUrl,
OpenworkServerError,
@@ -31,6 +32,7 @@ export type IdentitiesViewProps = {
openworkServerClient: OpenworkServerClient | null;
openworkReconnectBusy: boolean;
reconnectOpenworkServer: () => Promise;
+ restartLocalServer: () => Promise;
openworkServerWorkspaceId: string | null;
activeWorkspaceRoot: string;
developerMode: boolean;
@@ -85,6 +87,14 @@ function getTelegramUsernameFromResult(value: unknown): string | null {
return normalized || null;
}
+function readMessagingEnabledFromOpenworkConfig(value: unknown): boolean {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const record = value as Record;
+ const messaging = record.messaging;
+ if (!messaging || typeof messaging !== "object" || Array.isArray(messaging)) return false;
+ return (messaging as Record).enabled === true;
+}
+
/* ---- Brand channel icons ---- */
function TelegramIcon(props: { size?: number }) {
@@ -146,6 +156,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
const [telegramError, setTelegramError] = createSignal(null);
const [telegramBotUsername, setTelegramBotUsername] = createSignal(null);
const [telegramPairingCode, setTelegramPairingCode] = createSignal(null);
+ const [publicTelegramWarningOpen, setPublicTelegramWarningOpen] = createSignal(false);
const [slackBotToken, setSlackBotToken] = createSignal("");
const [slackAppToken, setSlackAppToken] = createSignal("");
@@ -178,6 +189,16 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
const [reconnectStatus, setReconnectStatus] = createSignal(null);
const [reconnectError, setReconnectError] = createSignal(null);
+ const [messagingEnabled, setMessagingEnabled] = createSignal(false);
+ const [messagingSaving, setMessagingSaving] = createSignal(false);
+ const [messagingStatus, setMessagingStatus] = createSignal(null);
+ const [messagingError, setMessagingError] = createSignal(null);
+ const [messagingRiskOpen, setMessagingRiskOpen] = createSignal(false);
+ const [messagingRestartRequired, setMessagingRestartRequired] = createSignal(false);
+ const [messagingRestartPromptOpen, setMessagingRestartPromptOpen] = createSignal(false);
+ const [messagingRestartBusy, setMessagingRestartBusy] = createSignal(false);
+ const [messagingDisableConfirmOpen, setMessagingDisableConfirmOpen] = createSignal(false);
+ const [messagingRestartAction, setMessagingRestartAction] = createSignal<"enable" | "disable">("enable");
const workspaceId = createMemo(() => {
const explicitId = props.openworkServerWorkspaceId?.trim() ?? "";
@@ -413,6 +434,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
setHealthError(null);
setTelegramIdentitiesError(null);
setSlackIdentitiesError(null);
+ setMessagingError(null);
if (!id) {
setHealth(null);
@@ -430,6 +452,26 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
return;
}
+ const config = await client.getConfig(id).catch(() => null);
+ const isModuleEnabled = readMessagingEnabledFromOpenworkConfig(config?.openwork);
+ setMessagingEnabled(isModuleEnabled);
+
+ if (!isModuleEnabled) {
+ setMessagingRestartRequired(false);
+ setHealth(null);
+ setHealthError(null);
+ setTelegramIdentities([]);
+ setTelegramIdentitiesError(null);
+ setTelegramBotUsername(null);
+ setTelegramPairingCode(null);
+ setSlackIdentities([]);
+ setSlackIdentitiesError(null);
+ if (!agentDirty() && !agentSaving()) {
+ void loadAgentFile();
+ }
+ return;
+ }
+
const [healthRes, tgRes, slackRes, telegramInfo] = await Promise.all([
client.getOpenCodeRouterHealth(id),
client.getOpenCodeRouterTelegramIdentities(id),
@@ -441,6 +483,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
if (isOpenCodeRouterSnapshot(healthRes.json)) {
setHealth(healthRes.json);
+ setMessagingRestartRequired(false);
} else {
setHealth(null);
if (!healthRes.ok) {
@@ -450,6 +493,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
: `OpenCodeRouter health unavailable (${healthRes.status})`;
setHealthError(message);
}
+ setMessagingRestartRequired(true);
}
if (isOpenCodeRouterIdentities(tgRes)) {
@@ -482,6 +526,9 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
setHealthError(message);
setTelegramIdentitiesError(message);
setSlackIdentitiesError(message);
+ if (messagingEnabled()) {
+ setMessagingRestartRequired(true);
+ }
} finally {
setRefreshing(false);
}
@@ -503,6 +550,95 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
setReconnectStatus("Reconnected.");
};
+ const enableMessagingModule = async () => {
+ if (messagingSaving()) return;
+ if (!serverReady()) return;
+ const id = workspaceId();
+ if (!id) return;
+ const client = openworkServerClient();
+ if (!client) return;
+
+ setMessagingSaving(true);
+ setMessagingStatus(null);
+ setMessagingError(null);
+ try {
+ await client.patchConfig(id, {
+ openwork: {
+ messaging: {
+ enabled: true,
+ },
+ },
+ });
+ setMessagingEnabled(true);
+ setMessagingRestartRequired(true);
+ setMessagingRiskOpen(false);
+ setMessagingRestartAction("enable");
+ setMessagingRestartPromptOpen(true);
+ setMessagingStatus("Messaging enabled. Restart this worker to apply before configuring channels.");
+ await refreshAll({ force: true });
+ } catch (error) {
+ setMessagingError(formatRequestError(error));
+ } finally {
+ setMessagingSaving(false);
+ }
+ };
+
+ const disableMessagingModule = async () => {
+ if (messagingSaving()) return;
+ if (!serverReady()) return;
+ const id = workspaceId();
+ if (!id) return;
+ const client = openworkServerClient();
+ if (!client) return;
+
+ setMessagingSaving(true);
+ setMessagingStatus(null);
+ setMessagingError(null);
+ try {
+ await client.patchConfig(id, {
+ openwork: {
+ messaging: {
+ enabled: false,
+ },
+ },
+ });
+ setMessagingEnabled(false);
+ setMessagingDisableConfirmOpen(false);
+ setMessagingRestartRequired(true);
+ setMessagingRestartAction("disable");
+ setMessagingRestartPromptOpen(true);
+ setMessagingStatus("Messaging disabled. Restart this worker to stop the messaging sidecar.");
+ await refreshAll({ force: true });
+ } catch (error) {
+ setMessagingError(formatRequestError(error));
+ } finally {
+ setMessagingSaving(false);
+ }
+ };
+
+ const restartMessagingWorker = async () => {
+ if (messagingRestartBusy()) return;
+ setMessagingRestartBusy(true);
+ setMessagingError(null);
+ setMessagingStatus(null);
+ try {
+ const ok = await props.restartLocalServer();
+ if (!ok) {
+ setMessagingError("Restart failed. Please restart the worker from Settings and try again.");
+ return;
+ }
+ setMessagingRestartPromptOpen(false);
+ setMessagingRestartRequired(false);
+ setMessagingStatus("Worker restarted. Refreshing messaging status...");
+ await refreshAll({ force: true });
+ setMessagingStatus("Worker restarted.");
+ } catch (error) {
+ setMessagingError(formatRequestError(error));
+ } finally {
+ setMessagingRestartBusy(false);
+ }
+ };
+
const upsertTelegram = async (access: "public" | "private") => {
if (telegramSaving()) return;
if (!serverReady()) return;
@@ -687,6 +823,16 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
setSendResult(null);
setReconnectStatus(null);
setReconnectError(null);
+ setMessagingEnabled(false);
+ setMessagingSaving(false);
+ setMessagingStatus(null);
+ setMessagingError(null);
+ setMessagingRiskOpen(false);
+ setMessagingRestartRequired(false);
+ setMessagingRestartPromptOpen(false);
+ setMessagingRestartBusy(false);
+ setMessagingDisableConfirmOpen(false);
+ setMessagingRestartAction("enable");
setActiveTab("general");
setExpandedChannel("telegram");
});
@@ -742,6 +888,12 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
{(value) => {value()}
}
+
+ {(value) => {value()}
}
+
+
+ {(value) => {value()}
}
+
{/* ---- Not connected to server ---- */}
@@ -761,30 +913,83 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
-
- setActiveTab("general")}
- >
- General
-
- setActiveTab("advanced")}
- >
- Advanced
-
-
+
+
+
+ setActiveTab("general")}
+ >
+ General
+
+ setActiveTab("advanced")}
+ >
+ Advanced
+
+
+
setMessagingDisableConfirmOpen(true)}
+ >
+ Disable messaging
+
+
+
-
+
+
+
Messaging is disabled by default
+
+ Messaging bots can execute actions against your local worker. If exposed publicly, they may allow access
+ to files, credentials, and API keys available to this worker.
+
+
+ Enable messaging only if you understand the risk and plan to secure access (for example, private Telegram
+ pairing).
+
+
+ setMessagingRiskOpen(true)}
+ >
+ {messagingSaving() ? "Enabling..." : "Enable messaging"}
+
+
+
+
+
+
+
+
+
+ Messaging is enabled in this workspace, but the messaging sidecar is not running yet. Restart this worker,
+ then return to Messaging settings to connect Telegram or Slack.
+
+ void restartMessagingWorker()}
+ >
+ {messagingRestartBusy() ? "Restarting..." : "Restart worker"}
+
+
+
+
{/* ---- Worker status card ---- */}
@@ -1009,7 +1214,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
void upsertTelegram("public")}
+ onClick={() => setPublicTelegramWarningOpen(true)}
disabled={telegramSaving() || !workspaceId() || !telegramToken().trim()}
class={`flex items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-semibold transition-colors ${
telegramSaving() || !workspaceId() || !telegramToken().trim()
@@ -1289,7 +1494,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
-
+
{/* ---- Message routing ---- */}
@@ -1510,6 +1715,79 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
+ {
+ if (messagingSaving()) return;
+ setMessagingRiskOpen(false);
+ }}
+ onConfirm={() => {
+ void enableMessagingModule();
+ }}
+ />
+
+ {
+ if (messagingRestartBusy()) return;
+ setMessagingRestartPromptOpen(false);
+ }}
+ onConfirm={() => {
+ void restartMessagingWorker();
+ }}
+ />
+
+ {
+ if (messagingSaving()) return;
+ setMessagingDisableConfirmOpen(false);
+ }}
+ onConfirm={() => {
+ void disableMessagingModule();
+ }}
+ />
+
+
+ Your bot will be accessible to the public and anyone who gets access to your bot will be able to have
+ full access to your local worker including any files or API keys that you've given it. If you create a
+ private bot, you can limit who can access it by requiring a pairing token. Are you sure you want to make
+ your bot public?
+ >
+ }
+ confirmLabel="Yes I understand the risk"
+ cancelLabel="Cancel"
+ variant="danger"
+ confirmButtonVariant="danger"
+ cancelButtonVariant="primary"
+ onCancel={() => setPublicTelegramWarningOpen(false)}
+ onConfirm={() => {
+ setPublicTelegramWarningOpen(false);
+ void upsertTelegram("public");
+ }}
+ />
+
);
diff --git a/apps/app/src/app/pages/mcp.tsx b/apps/app/src/app/pages/mcp.tsx
index 05fd674f..ea2cbe7c 100644
--- a/apps/app/src/app/pages/mcp.tsx
+++ b/apps/app/src/app/pages/mcp.tsx
@@ -4,10 +4,18 @@ import type { McpServerEntry, McpStatusMap } from "../types";
import type { McpDirectoryInfo } from "../constants";
import { formatRelativeTime, isTauriRuntime, isWindowsPlatform } from "../utils";
import { readOpencodeConfig, type OpencodeConfigFile } from "../lib/tauri";
+import {
+ buildChromeDevtoolsCommand,
+ getMcpIdentityKey,
+ isChromeDevtoolsMcp,
+ normalizeMcpSlug,
+ usesChromeDevtoolsAutoConnect,
+} from "../mcp";
import Button from "../components/button";
import AddMcpModal from "../components/add-mcp-modal";
import ConfirmModal from "../components/confirm-modal";
+import ControlChromeSetupModal from "../components/control-chrome-setup-modal";
import {
BookOpen,
CheckCircle2,
@@ -23,6 +31,7 @@ import {
Plug2,
Plus,
RefreshCw,
+ Settings,
Settings2,
Unplug,
Zap,
@@ -145,6 +154,9 @@ export default function McpView(props: McpViewProps) {
const [revealBusy, setRevealBusy] = createSignal(false);
const [showAdvanced, setShowAdvanced] = createSignal(false);
const [addMcpModalOpen, setAddMcpModalOpen] = createSignal(false);
+ const [controlChromeModalOpen, setControlChromeModalOpen] = createSignal(false);
+ const [controlChromeModalMode, setControlChromeModalMode] = createSignal<"connect" | "edit">("connect");
+ const [controlChromeExistingProfile, setControlChromeExistingProfile] = createSignal(false);
const selectedEntry = createMemo(() =>
props.mcpServers.find((entry) => entry.name === props.selectedMcp) ?? null,
@@ -230,16 +242,35 @@ export default function McpView(props: McpViewProps) {
}
};
- const toSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
+ const resolveQuickConnectMatch = (name: string) =>
+ quickConnectList().find((candidate) => {
+ const candidateKey = getMcpIdentityKey(candidate);
+ return candidateKey === name || candidate.name === name || normalizeMcpSlug(candidate.name) === name;
+ });
- const quickConnectStatus = (name: string) => {
- const slug = toSlug(name);
- return props.mcpStatuses[slug];
+ const displayName = (name: string) => resolveQuickConnectMatch(name)?.name ?? name;
+
+ const quickConnectStatus = (entry: McpDirectoryInfo) => props.mcpStatuses[getMcpIdentityKey(entry)];
+
+ const isQuickConnectConfigured = (entry: McpDirectoryInfo) =>
+ props.mcpServers.some((server) => server.name === getMcpIdentityKey(entry));
+
+ const openControlChromeModal = (mode: "connect" | "edit", existingEntry?: McpServerEntry | null) => {
+ setControlChromeModalMode(mode);
+ setControlChromeExistingProfile(usesChromeDevtoolsAutoConnect(existingEntry?.config.command));
+ setControlChromeModalOpen(true);
};
- const isQuickConnectConnected = (name: string) => {
- const status = quickConnectStatus(name);
- return status?.status === "connected";
+ const saveControlChromeSettings = (useExistingProfile: boolean) => {
+ const controlChrome = quickConnectList().find((entry) => isChromeDevtoolsMcp(entry));
+ if (!controlChrome) return;
+ const existingEntry = props.mcpServers.find((entry) => isChromeDevtoolsMcp(entry.name));
+
+ props.connectMcp({
+ ...controlChrome,
+ command: buildChromeDevtoolsCommand(existingEntry?.config.command ?? controlChrome.command, useExistingProfile),
+ });
+ setControlChromeModalOpen(false);
};
const canConnect = () => !props.busy;
@@ -338,70 +369,93 @@ export default function McpView(props: McpViewProps) {
{(entry) => {
- const connected = () => isQuickConnectConnected(entry.name);
- const connecting = () => props.mcpConnectingName === entry.name;
- const Icon = serviceIcon(entry.name);
+ const configured = () => isQuickConnectConfigured(entry);
+ const connecting = () => props.mcpConnectingName === entry.name;
+ const Icon = serviceIcon(entry.name);
+ const isControlChrome = () => isChromeDevtoolsMcp(entry);
- return (
- { if (!connected()) props.connectMcp(entry); }}
- class={`group text-left rounded-xl border p-4 transition-all ${
- connected()
- ? "border-green-6 bg-green-2"
- : "border-dls-border bg-dls-surface hover:bg-dls-hover hover:shadow-[0_4px_16px_rgba(17,24,39,0.06)]"
- }`}
- >
-
- {/* Icon */}
-
-
}
+ return (
+
+
+ {
+ event.stopPropagation();
+ const existingEntry = props.mcpServers.find((server) => server.name === getMcpIdentityKey(entry));
+ openControlChromeModal("edit", existingEntry);
+ }}
>
- }
- >
-
-
-
-
+
+
+
- {/* Text */}
-
-
-
{entry.name}
-
-
- {tr("mcp.connected_badge")}
-
-
-
- {(status) => (
-
- {friendlyStatus(status().status, locale())}
-
- )}
-
-
-
- {entry.description}
-
-
-
- {tr("mcp.tap_to_connect")}
+
{
+ if (configured()) return;
+ if (isControlChrome()) {
+ openControlChromeModal("connect");
+ return;
+ }
+ props.connectMcp(entry);
+ }}
+ class={`group w-full text-left rounded-xl border p-4 transition-all ${
+ configured()
+ ? "border-green-6 bg-green-2"
+ : "border-dls-border bg-dls-surface hover:bg-dls-hover hover:shadow-[0_4px_16px_rgba(17,24,39,0.06)]"
+ }`}
+ >
+
+
+ }
+ >
+ }
+ >
+
+
+
-
-
+
+
+
+
{entry.name}
+
+
+ {tr("mcp.connected_badge")}
+
+
+
+ {(status) => (
+
+ {friendlyStatus(status().status, locale())}
+
+ )}
+
+
+
+ {entry.description}
+
+
+
+ {tr("mcp.tap_to_connect")}
+
+
+
+
+
-
- );
- }}
-
+ );
+ }}
+
@@ -459,7 +513,7 @@ export default function McpView(props: McpViewProps) {
-
{entry.name}
+
{displayName(entry.name)}
@@ -557,7 +611,17 @@ export default function McpView(props: McpViewProps) {
-
+
+
+ openControlChromeModal("edit", entry)}
+ >
+
+ {tr("mcp.control_chrome_edit")}
+
+
+
+
setControlChromeModalOpen(false)}
+ onSave={(useExistingProfile) => saveControlChromeSettings(useExistingProfile)}
+ />
);
}
diff --git a/apps/app/src/app/pages/session.tsx b/apps/app/src/app/pages/session.tsx
index b47b5fa1..421e3918 100644
--- a/apps/app/src/app/pages/session.tsx
+++ b/apps/app/src/app/pages/session.tsx
@@ -146,6 +146,9 @@ export type SessionViewProps = {
openworkServerDiagnostics: OpenworkServerDiagnostics | null;
openworkServerSettings: OpenworkServerSettings;
openworkServerHostInfo: OpenworkServerInfo | null;
+ shareRemoteAccessBusy: boolean;
+ shareRemoteAccessError: string | null;
+ saveShareRemoteAccess: (enabled: boolean) => Promise;
openworkServerWorkspaceId: string | null;
engineInfo: EngineInfo | null;
engineDoctorVersion: string | null;
@@ -341,6 +344,47 @@ const MAIN_THREAD_LAG_WARN_MS = 180;
type CommandPaletteMode = "root" | "sessions";
+function describePermissionRequest(permission: PendingPermission | null) {
+ if (!permission) {
+ return {
+ title: "Permission Required",
+ message: "OpenCode is requesting permission to continue.",
+ permissionLabel: "",
+ scopeLabel: "Scope",
+ scopeValue: "",
+ isDoomLoop: false,
+ note: null as string | null,
+ };
+ }
+
+ const patterns = permission.patterns.filter((pattern) => pattern.trim().length > 0);
+ if (permission.permission === "doom_loop") {
+ const tool =
+ permission.metadata && typeof permission.metadata === "object" && typeof permission.metadata.tool === "string"
+ ? permission.metadata.tool
+ : null;
+ return {
+ title: "Doom Loop Detected",
+ message: "OpenCode detected repeated tool calls with identical input and is asking whether it should continue after repeated failures.",
+ permissionLabel: "Doom Loop",
+ scopeLabel: tool ? "Tool" : "Repeated call",
+ scopeValue: tool ?? (patterns.length ? patterns.join(", ") : "Repeated tool call"),
+ isDoomLoop: true,
+ note: "Reject to stop the loop, or allow if you want the agent to keep trying.",
+ };
+ }
+
+ return {
+ title: "Permission Required",
+ message: "OpenCode is requesting permission to continue.",
+ permissionLabel: permission.permission,
+ scopeLabel: "Scope",
+ scopeValue: patterns.join(", "),
+ isDoomLoop: false,
+ note: null as string | null,
+ };
+}
+
export default function SessionView(props: SessionViewProps) {
const platform = usePlatform();
let messagesEndEl: HTMLDivElement | undefined;
@@ -362,6 +406,9 @@ export default function SessionView(props: SessionViewProps) {
const topInitializedSessionIds = new Set();
const [toastMessage, setToastMessage] = createSignal(null);
+ const activePermissionPresentation = createMemo(() =>
+ describePermissionRequest(props.activePermission),
+ );
const [providerAuthActionBusy, setProviderAuthActionBusy] =
createSignal(false);
const [renameModalOpen, setRenameModalOpen] = createSignal(false);
@@ -3007,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() ||
@@ -4816,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}
@@ -4852,14 +4910,16 @@ export default function SessionView(props: SessionViewProps) {
-
+ }>
+
+
- Permission Required
+ {activePermissionPresentation().title}
- OpenCode is requesting permission to continue.
+ {activePermissionPresentation().message}
@@ -4869,15 +4929,21 @@ export default function SessionView(props: SessionViewProps) {
Permission
- {props.activePermission?.permission}
+ {activePermissionPresentation().permissionLabel}
+
+
+ {activePermissionPresentation().note}
+
+
+
- Scope
+ {activePermissionPresentation().scopeLabel}
- {props.activePermission?.patterns.join(", ")}
+ {activePermissionPresentation().scopeValue}
Promise;
+ 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));
@@ -1521,7 +1529,7 @@ export default function SettingsView(props: SettingsViewProps) {
}
>
-
+
{(hint) => (
@@ -1603,7 +1611,7 @@ export default function SettingsView(props: SettingsViewProps) {
>
props.setAuthorizedFolderDraft(event.currentTarget.value)
@@ -1624,7 +1632,7 @@ export default function SettingsView(props: SettingsViewProps) {
void props.pickAuthorizedFolder()}
disabled={
props.authorizedFoldersLoading ||
@@ -1639,7 +1647,7 @@ export default function SettingsView(props: SettingsViewProps) {
+
+
+
OpenCode
+
+ Runtime options for the local engine and orchestrator bridge.
+
+
+
+
+
+
Enable Exa web search
+
+ Applies when OpenWork Orchestrator launches OpenCode. Off by
+ default until the integration is fully rolled out.
+
+
+
+ {props.opencodeEnableExa ? "On" : "Off"}
+
+
+
+
+ Restart OpenCode or the orchestrator after changing this setting.
+
+
+
Developer mode
@@ -2889,27 +2928,6 @@ export default function SettingsView(props: SettingsViewProps) {
-
-
-
-
Enable Exa web search
-
- Advanced. Applies when OpenWork Orchestrator launches OpenCode. Off by default until the integration is fully rolled out.
-
-
-
- {props.opencodeEnableExa ? "On" : "Off"}
-
-
-
-
- Restart OpenCode or the orchestrator after changing this setting.
-
diff --git a/apps/app/src/i18n/index.ts b/apps/app/src/i18n/index.ts
index 1d5fe5d0..e35a141f 100644
--- a/apps/app/src/i18n/index.ts
+++ b/apps/app/src/i18n/index.ts
@@ -3,18 +3,19 @@ import en from "./locales/en";
import ja from "./locales/ja";
import zh from "./locales/zh";
import vi from "./locales/vi";
+import ptBR from "./locales/pt-BR";
import { LANGUAGE_PREF_KEY } from "../app/constants";
/**
* Supported languages
*/
-export type Language = "en" | "ja" | "zh" | "vi";
+export type Language = "en" | "ja" | "zh" | "vi" | "pt-BR";
export type Locale = Language;
/**
* All supported languages - single source of truth
*/
-export const LANGUAGES: Language[] = ["en", "ja", "zh", "vi"];
+export const LANGUAGES: Language[] = ["en", "ja", "zh", "vi", "pt-BR"];
/**
* Language options for UI - single source of truth
@@ -24,6 +25,7 @@ export const LANGUAGE_OPTIONS = [
{ value: "ja" as Language, label: "日本語", nativeName: "日本語" },
{ value: "zh" as Language, label: "简体中文", nativeName: "简体中文" },
{ value: "vi" as Language, label: "Vietnamese", nativeName: "Tiếng Việt" },
+ { value: "pt-BR" as Language, label: "Portuguese (BR)", nativeName: "Português (BR)" },
] as const;
/**
@@ -34,6 +36,7 @@ const TRANSLATIONS: Record
> = {
ja,
zh,
vi,
+ "pt-BR": ptBR,
};
/**
@@ -89,6 +92,7 @@ export const setLocale = (newLocale: Language) => {
*/
export const t = (key: string, localeOverride?: Language): string => {
const loc = localeOverride ?? locale();
+1
// Try target language first
if (TRANSLATIONS[loc]?.[key]) {
diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts
index 455c71c2..62063adf 100644
--- a/apps/app/src/i18n/locales/en.ts
+++ b/apps/app/src/i18n/locales/en.ts
@@ -474,6 +474,23 @@ export default {
"mcp.add_server_button": "Add server",
"mcp.name_required": "Enter a server name.",
"mcp.url_or_command_required": "Enter a URL for remote or a command for local servers.",
+ "mcp.control_chrome_setup_title": "Set up Control Chrome",
+ "mcp.control_chrome_setup_subtitle": "Turn on Chrome access, then choose whether OpenWork should use its own clean profile or attach to the Chrome you already use.",
+ "mcp.control_chrome_browser_title": "1. Turn on Chrome access",
+ "mcp.control_chrome_browser_hint": "In Chrome 144 or newer, do this first:",
+ "mcp.control_chrome_browser_step_one": "Open chrome://inspect/#remote-debugging.",
+ "mcp.control_chrome_browser_step_two": "Enable remote debugging.",
+ "mcp.control_chrome_browser_step_three": "Allow incoming debugging connections when Chrome asks.",
+ "mcp.control_chrome_docs": "Official MCP guide",
+ "mcp.control_chrome_profile_title": "2. Choose which Chrome to use",
+ "mcp.control_chrome_profile_hint": "Control Chrome normally opens a separate Chrome profile. Turn this on if you want OpenWork to reuse the Chrome window you already have open.",
+ "mcp.control_chrome_toggle_label": "Use my existing Chrome profile",
+ "mcp.control_chrome_toggle_hint": "When this is on, OpenWork adds --autoConnect so the MCP attaches to a Chrome instance you already started.",
+ "mcp.control_chrome_toggle_on": "OpenWork will reuse your current tabs, cookies, and sign-ins.",
+ "mcp.control_chrome_toggle_off": "OpenWork will launch a separate Chrome profile just for automation.",
+ "mcp.control_chrome_connect": "Add Control Chrome",
+ "mcp.control_chrome_save": "Save settings",
+ "mcp.control_chrome_edit": "Edit settings",
"mcp.logout_label": "OAuth",
"mcp.logout_action": "Log out",
diff --git a/apps/app/src/i18n/locales/index.ts b/apps/app/src/i18n/locales/index.ts
index 0e681896..5dc63519 100644
--- a/apps/app/src/i18n/locales/index.ts
+++ b/apps/app/src/i18n/locales/index.ts
@@ -5,3 +5,4 @@ export { default as en } from "./en";
export { default as ja } from "./ja";
export { default as zh } from "./zh";
export { default as vi } from "./vi";
+export { default as pt-BR } from "./pt-BR";
diff --git a/apps/app/src/i18n/locales/pt-BR.ts b/apps/app/src/i18n/locales/pt-BR.ts
new file mode 100644
index 00000000..81a06fb6
--- /dev/null
+++ b/apps/app/src/i18n/locales/pt-BR.ts
@@ -0,0 +1,861 @@
+/**
+ * Traduções para português do Brasil
+ * Termos profissionais (Skills, Plugins, Commands, Sessions, OpenCode, OpenPackage, OpenWork) NÃO são traduzidos
+ */
+
+export default {
+ // ==================== Dashboard ====================
+ "dashboard.title": "Dashboard",
+ "dashboard.sessions": "Sessões",
+ "dashboard.commands": "Comandos",
+ "dashboard.skills": "Skills",
+ "dashboard.plugins": "Plugins",
+ "dashboard.mcps": "Apps",
+ "dashboard.settings": "Configurações",
+ "dashboard.home": "Home",
+ "dashboard.runs": "Execuções",
+ "dashboard.find_workspace": "Buscar workspace...",
+ "dashboard.workspaces": "Workspaces",
+ "dashboard.no_workspaces": "Nenhum workspace encontrado.",
+ "dashboard.new_workspace": "Novo Workspace...",
+ "dashboard.new_remote_workspace": "Adicionar Workspace Remoto...",
+ "dashboard.forget_workspace": "Esquecer workspace",
+ "dashboard.remote": "Remoto",
+ "dashboard.connection": "Conexão",
+ "dashboard.local_engine": "Engine Local",
+ "dashboard.client_mode": "Modo Cliente",
+ "dashboard.connected": "Conectado",
+ "dashboard.not_connected": "Não conectado",
+ "dashboard.stop_disconnect": "Parar e Desconectar",
+ "dashboard.disconnect": "Desconectar",
+ "dashboard.new_task": "Nova Tarefa",
+ "dashboard.new": "Novo",
+ "dashboard.busy": "Ocupado",
+ "dashboard.hero_title": "O que vamos fazer hoje?",
+ "dashboard.hero_description": "Descreva um resultado. O OpenWork vai executar e manter um histórico de auditoria.",
+ "dashboard.quick_start_commands": "Comandos de Início Rápido",
+ "dashboard.view_all": "Ver todos",
+ "dashboard.no_commands": "Nenhum comando ainda. Comandos iniciais aparecerão aqui.",
+ "dashboard.run_command": "Executar um comando salvo",
+ "dashboard.recent_sessions": "Sessões Recentes",
+ "dashboard.this_workspace": "este workspace",
+ "dashboard.no_sessions": "Nenhuma sessão ainda.",
+ "dashboard.idle": "Ocioso",
+ "dashboard.running": "Em execução",
+ "dashboard.completed": "Concluído",
+ "dashboard.failed": "Falhou",
+ "dashboard.repairing_cache": "Reparando cache",
+ "dashboard.repair_cache": "Reparar cache",
+ "dashboard.retry": "Tentar novamente",
+ "dashboard.alpha": "Alpha",
+ "dashboard.create_workspace_title": "Criar Workspace",
+ "dashboard.create_workspace_subtitle": "Inicializar um novo workspace baseado em pasta.",
+ "dashboard.create_workspace_confirm": "Criar Workspace",
+ "dashboard.create_sandbox_confirm": "Criar como sandbox",
+ "share_skill_destination.title": "Para onde esta skill deve ir?",
+ "share_skill_destination.subtitle": "Escolha um workspace existente ou crie um novo antes de importar esta skill compartilhada.",
+ "share_skill_destination.skill_label": "Skill compartilhada",
+ "share_skill_destination.fallback_skill_name": "Skill compartilhada",
+ "share_skill_destination.trigger_label": "Gatilho",
+ "share_skill_destination.current_badge": "Atual",
+ "share_skill_destination.existing_workers": "Workspaces existentes",
+ "share_skill_destination.no_workers": "Nenhum workspace está pronto ainda. Crie um ou conecte um workspace remoto para instalar esta skill.",
+ "share_skill_destination.new_destination": "Novo destino",
+ "share_skill_destination.selection_ready": "Pronto para adicionar",
+ "share_skill_destination.selected_badge": "Selecionado",
+ "share_skill_destination.selected_hint": "Selecionado. Revise o destino abaixo e confirme.",
+ "share_skill_destination.footer_idle": "Escolha um workspace para continuar.",
+ "share_skill_destination.footer_selected": "Workspace selecionado:",
+ "share_skill_destination.confirm_button": "Adicionar skill ao workspace",
+ "share_skill_destination.confirm_busy": "Adicionando skill...",
+ "share_skill_destination.local_badge": "Local",
+ "share_skill_destination.remote_badge": "Remoto",
+ "share_skill_destination.sandbox_badge": "Sandbox",
+ "share_skill_destination.create_worker": "Criar novo workspace",
+ "share_skill_destination.create_worker_desc": "Abrir o fluxo de configuração do workspace e adicionar esta skill após o novo workspace estar pronto.",
+ "share_skill_destination.connect_remote": "Conectar workspace remoto",
+ "share_skill_destination.connect_remote_desc": "Vincular um host OpenWork e selecioná-lo na lista para importar esta skill.",
+ "dashboard.sandbox_get_ready_title": "Sandboxes precisam do Docker",
+ "dashboard.sandbox_get_ready_action": "Preparar o sistema",
+ "dashboard.sandbox_get_ready_desc": "Execute este workspace em um container Docker isolado para execuções mais seguras e reproduzíveis.",
+ "dashboard.sandbox_checking_docker": "Verificando Docker...",
+ "dashboard.create_remote_workspace_title": "Adicionar Workspace Remoto",
+ "dashboard.create_remote_workspace_subtitle": "Salvar um servidor OpenWork como workspace.",
+ "dashboard.create_remote_workspace_confirm": "Adicionar Workspace",
+ "dashboard.edit_remote_workspace_title": "Editar Conexão Remota",
+ "dashboard.edit_remote_workspace_subtitle": "Atualizar os dados do servidor OpenWork para este workspace.",
+ "dashboard.edit_remote_workspace_confirm": "Salvar conexão",
+ "dashboard.remote_workspace_title": "Workspace remoto",
+ "dashboard.remote_workspace_hint": "Acompanhe um servidor OpenWork e reconecte a qualquer momento.",
+ "dashboard.remote_base_url_label": "URL do servidor OpenWork",
+ "dashboard.remote_base_url_placeholder": "http://127.0.0.1:8787",
+ "dashboard.remote_base_url_required": "Adicione uma URL de servidor para continuar.",
+ "dashboard.openwork_host_label": "URL do servidor OpenWork",
+ "dashboard.openwork_host_placeholder": "https://seu-servidor.openwork.app",
+ "dashboard.openwork_host_hint": "Use a URL fornecida pelo seu servidor OpenWork.",
+ "dashboard.openwork_host_token_label": "Token de colaborador ou proprietário",
+ "dashboard.openwork_host_token_placeholder": "Cole seu token",
+ "dashboard.openwork_host_token_hint": "Opcional. Cole um token de colaborador para acesso rotineiro ou um token de proprietário quando este cliente precisar responder a prompts de permissão.",
+ "dashboard.remote_mode_openwork_alpha": "Servidor OpenWork",
+ "dashboard.remote_mode_direct": "Direto (legado)",
+ "dashboard.remote_connection_openwork": "OpenWork",
+ "dashboard.remote_connection_direct": "Direto",
+ "dashboard.remote_directory_label": "Diretório do workspace (opcional)",
+ "dashboard.remote_directory_placeholder": "/home/equipe/projeto",
+ "dashboard.remote_directory_hint": "Deixe em branco para usar o padrão do servidor.",
+ "dashboard.remote_display_name_label": "Nome de exibição (opcional)",
+ "dashboard.remote_display_name_placeholder": "Workspace da equipe de design",
+ "dashboard.select_folder": "Selecionar Pasta",
+ "dashboard.choose_folder": "Escolher uma pasta",
+ "dashboard.choose_folder_next": "Compartilhar arquivos com seu workspace.",
+ "dashboard.change": "Alterar",
+ "dashboard.opening": "Abrindo...",
+ "dashboard.choose_preset": "Escolher Predefinição",
+ "dashboard.choose_folder_continue": "Escolha uma pasta para continuar.",
+ "dashboard.starter_workspace": "Workspace inicial",
+ "dashboard.starter_workspace_desc": "Pré-configurado para mostrar como usar plugins, comandos e skills.",
+ "dashboard.empty_workspace": "Workspace vazio",
+ "dashboard.empty_workspace_desc": "Comece com uma pasta em branco e adicione o que precisar.",
+ "dashboard.blueprints_workspace": "Blueprints",
+ "dashboard.blueprints_workspace_desc": "Comece com um workspace pronto para automação com skills reutilizáveis, comandos e fluxos compartilhados.",
+
+ // ==================== Workspace ====================
+ "workspace.rename_title": "Editar nome do workspace",
+ "workspace.rename_description": "Atualizar o nome exibido na barra lateral.",
+ "workspace.rename_label": "Nome do workspace",
+ "workspace.rename_placeholder": "Workspace da equipe de design",
+
+ // ==================== Session ====================
+ "session.no_selected": "Nenhuma sessão selecionada",
+ "session.back_to_dashboard": "Voltar ao dashboard",
+ "session.new_task": "Nova tarefa",
+ "session.recents": "Recentes",
+ "session.recents_notice": "Estas tarefas são executadas localmente e não sincronizam entre dispositivos.",
+ "session.ready_to_work_title": "Pronto para trabalhar",
+ "session.ready_to_work_description": "Descreva uma tarefa. Vou mostrar o progresso e pedir permissões quando necessário.",
+ "session.document_label": "Documento",
+ "session.standard_label": "Padrão",
+ "session.no_artifacts_fallback": "Nenhum artefato ainda.",
+ "session.active_plugins_label": "Plugins ativos",
+ "session.active_plugins_count": "{count}",
+ "session.no_plugins_loaded": "Nenhum plugin carregado.",
+ "session.selected_folders_label": "Pastas selecionadas",
+ "session.selected_folders_count": "{count}",
+ "session.working_files_label": "Arquivos de trabalho",
+ "session.none_yet_label": "Nenhum ainda.",
+ "session.permission_required_title": "Permissão Necessária",
+ "session.permission_required_description": "O OpenCode está solicitando permissão para continuar.",
+ "session.permission_label_uppercase": "Permissão",
+ "session.scope_label_uppercase": "Escopo",
+ "session.details_label": "Detalhes",
+ "session.steps_notice_text": "Os passos serão exibidos conforme a tarefa avança.",
+ "session.ready_to_work": "Pronto para trabalhar",
+ "session.ready_description": "Descreva uma tarefa. Vou mostrar o progresso e pedir permissões quando necessário.",
+ "session.hide_steps": "Ocultar passos",
+ "session.view_steps": "Ver passos",
+ "session.open": "Abrir",
+ "session.reveal": "Mostrar",
+ "session.opened_toast": "Aberto no app padrão.",
+ "session.revealed_toast": "Exibido no gerenciador de arquivos.",
+ "session.artifact_path_missing": "Caminho do artefato ausente.",
+ "session.desktop_only": "Abrir está disponível apenas no app desktop.",
+ "session.open_failed": "Não foi possível abrir o artefato.",
+ "session.model": "Modelo",
+ "session.ready": "Pronto",
+ "session.connect_provider": "Conecte um provedor para personalizar isso.",
+ "session.running": "Em execução",
+ "session.progress": "Progresso",
+ "session.steps_notice": "Os passos serão exibidos conforme a tarefa avança.",
+ "session.artifacts": "Artefatos",
+ "session.no_artifacts": "Nenhum artefato ainda.",
+ "session.context": "Contexto",
+ "session.active_plugins": "Plugins ativos",
+ "session.no_plugins": "Nenhum plugin carregado.",
+ "session.selected_folders": "Pastas selecionadas",
+ "session.working_files": "Arquivos de trabalho",
+ "session.none_yet": "Nenhum ainda.",
+ "session.document": "Documento",
+ "session.standard": "Padrão",
+ "session.try_notion_prompt": "Experimente agora: configure meu CRM no Notion",
+ "session.insert_prompt": "Inserir prompt",
+ "session.placeholder": "Pergunte ao OpenWork...",
+ "session.run": "Executar",
+ "session.permission_required": "Permissão Necessária",
+ "session.permission_description": "O OpenCode está solicitando permissão para continuar.",
+ "session.permission_label": "Permissão",
+ "session.scope_label": "Escopo",
+ "session.details": "Detalhes",
+ "session.deny": "Negar",
+ "session.once": "Uma vez",
+ "session.allow_for_session": "Permitir para esta sessão",
+ "session.tasks_local_hint": "Estas tarefas são executadas localmente e não sincronizam entre dispositivos.",
+ "session.recents_label": "Recentes",
+ "session.model_standard": "Padrão",
+ "session.run_button_title": "Executar",
+ "session.rename_title": "Renomear sessão",
+ "session.rename_description": "Atualizar o nome desta sessão.",
+ "session.rename_label": "Nome da sessão",
+ "session.rename_placeholder": "Digite um novo nome",
+
+ // ==================== Commands ====================
+ "commands.new": "Novo",
+ "commands.empty_state": "Salve um prompt ou comando para executá-lo novamente com um toque.",
+ "commands.workspace": "Este workspace",
+ "commands.global": "Todos os workspaces",
+ "commands.other": "Outros comandos",
+ "commands.run": "Executar",
+ "commands.modal_title": "Salvar um fluxo reutilizável",
+ "commands.modal_description": "Armazene um prompt ou comando para reutilização rápida.",
+ "commands.name_label": "Nome do comando",
+ "commands.name_placeholder": "ex: daily-standup",
+ "commands.name_hint": "Isso se torna /daily-standup no OpenCode.",
+ "commands.description_label": "Descrição (opcional)",
+ "commands.description_placeholder": "O que este comando faz?",
+ "commands.template_label": "Instruções",
+ "commands.template_placeholder": "Escreva as instruções que deseja reutilizar…",
+ "commands.template_hint": "Use $ARGUMENTS para aceitar detalhes.",
+ "commands.details_required": "Detalhes",
+ "commands.default_description": "Executar um comando salvo",
+ "commands.command_label": "Comando",
+ "commands.details_label": "Detalhes",
+ "commands.details_placeholder": "Adicionar detalhes opcionais",
+ "commands.details_hint": "Estes detalhes são passados ao comando.",
+ "commands.run_modal_title": "Executar comando",
+ "commands.run_modal_description": "Adicione detalhes opcionais antes de executar.",
+ "commands.run_modal_run": "Executar comando",
+ "commands.name_will_be": "Será criado como",
+ "commands.override_title": "Substituir comando existente?",
+ "commands.override_description": "Já existe um comando com este nome.",
+ "commands.override_warning": "Isso substituirá o comando \"{name}\" existente. Esta ação não pode ser desfeita.",
+ "commands.override_confirm": "Substituir",
+ "commands.override_cancel": "Manter existente",
+
+ // ==================== Skills ====================
+ "skills.title": "Skills",
+ "skills.subtitle": "Gerenciar skills para este workspace.",
+ "skills.refresh": "Atualizar",
+ "skills.add_title": "Adicionar skills",
+ "skills.add_description": "Instale um comando inicial, importe uma skill ou abra a pasta.",
+ "skills.install_from_openpackage": "Instalar do OpenPackage",
+ "skills.host_mode_only": "Apenas workspace local",
+ "skills.install": "Instalar",
+ "skills.installed_label": "Instalado",
+ "skills.install_hint": "Instala pacotes OpenPackage no workspace atual. As skills devem ficar em `.opencode/skills`.",
+ "skills.import_local": "Importar skill local",
+ "skills.import_local_hint": "Copiar uma pasta de skill existente para este workspace.",
+ "skills.import": "Importar",
+ "skills.curated_packages": "Pacotes selecionados",
+ "skills.view": "Visualizar",
+ "skills.search_placeholder": "Buscar pacotes ou listas (ex: claude, registry, community)",
+ "skills.no_matches": "Nenhuma correspondência. Tente uma busca diferente.",
+ "skills.install_package": "Instalar",
+ "skills.registry_notice": "Publicar no registro OpenPackage (`opkg push`) requer autenticação. Uma busca no registro e sincronização de lista está planejada.",
+ "skills.installed": "Skills instaladas",
+ "skills.no_skills": "Nenhuma skill detectada em `.opencode/skills`, `.claude/skills` ou `~/.agents/skills`.",
+ "skills.desktop_required": "O gerenciamento de skills requer o app desktop.",
+ "skills.host_only_error": "O gerenciamento de skills requer um workspace local ou servidor OpenWork conectado.",
+ "skills.install_skill_creator": "Instalar criador de skills",
+ "skills.install_skill_creator_hint": "Esta skill permite criar outras skills diretamente pelo chat.",
+ "skills.installing_skill_creator": "Instalando criador de skills...",
+ "skills.skill_creator_installed": "Criador de skills instalado.",
+ "skills.skill_creator_already_installed": "O criador de skills já está instalado.",
+ "skills.install_failed": "Falha na instalação da skill.",
+ "skills.reveal_folder": "Abrir pasta de skills",
+ "skills.reveal_folder_hint": "Abrir o diretório de skills no Finder.",
+ "skills.reveal_button": "Mostrar no Finder",
+ "skills.reveal_failed": "Falha ao abrir a pasta de skills.",
+ "skills.uninstall": "Desinstalar",
+ "skills.uninstall_title": "Desinstalar skill?",
+ "skills.uninstall_warning": "Isso excluirá permanentemente a skill `{name}` do seu workspace.",
+ "skills.uninstall_failed": "Falha ao desinstalar a skill.",
+ "skills.uninstalled": "Skill removida.",
+ "skills.source_placeholder": "github:anthropics/claude-code",
+ "skills.notion_crm_title": "Skills de Enriquecimento do Notion CRM",
+ "skills.notion_crm_description": "Adicionar fluxos de enriquecimento para contatos, pipelines e acompanhamentos.",
+ "skills.notion_crm_card_description": "Enriqueça dados do Notion CRM com skills prontas.",
+ "skills.connect_host_to_load": "Conecte um servidor OpenWork para carregar skills.",
+ "skills.pick_workspace_first": "Escolha primeiro uma pasta de workspace.",
+ "skills.no_skills_found": "Nenhuma skill encontrada ainda.",
+ "skills.installed_description": "Skills disponíveis neste workspace.",
+ "skills.failed_to_load": "Falha ao carregar skills",
+ "skills.plugin_management_host_only": "O gerenciamento de plugins requer o app desktop.",
+ "skills.plugins_host_only": "Plugins estão disponíveis apenas no app desktop.",
+ "skills.pick_project_for_plugins": "Escolha uma pasta de projeto para gerenciar os plugins do projeto.",
+ "skills.pick_project_for_active": "Escolha uma pasta de projeto para carregar os plugins ativos.",
+ "skills.no_opencode_found": "Nenhum opencode.json encontrado ainda. Adicione um plugin para criar um.",
+ "skills.no_opencode_workspace": "Nenhum opencode.json neste workspace ainda.",
+ "skills.failed_parse_opencode": "Falha ao processar opencode.json",
+ "skills.failed_load_opencode": "Falha ao carregar opencode.json",
+ "skills.failed_load_active": "Falha ao carregar plugins ativos.",
+ "skills.enter_plugin_name": "Digite o nome do pacote do plugin.",
+ "skills.plugin_already_listed": "Plugin já listado no opencode.json.",
+ "skills.failed_update_opencode": "Falha ao atualizar opencode.json",
+ "skills.opackage_install_host_only": "Instalações OpenPackage requerem o app desktop.",
+ "skills.pick_project_first": "Escolha primeiro uma pasta de projeto.",
+ "skills.enter_opackage_source": "Digite a fonte do OpenPackage (ex: github:anthropics/claude-code).",
+ "skills.installing_opackage": "Instalando OpenPackage...",
+ "skills.install_complete": "Instalado.",
+ "skills.curated_list_notice": "Esta é uma lista selecionada, não um OpenPackage ainda. Copie o link ou aguarde o PRD para a integração de busca no registro.",
+ "skills.import_host_only": "Importação de skill requer o app desktop.",
+ "skills.select_skill_folder": "Selecionar pasta da skill",
+ "skills.import_failed": "Falha na importação ({status})",
+ "skills.imported": "Importado.",
+ "skills.unknown_error": "Erro desconhecido",
+
+ // ==================== Plugins ====================
+ "plugins.title": "Plugins OpenCode",
+ "plugins.description": "Gerenciar `opencode.json` para os plugins do seu projeto ou globais do OpenCode.",
+ "plugins.config_label": "Config",
+ "plugins.config_not_loaded": "Ainda não carregado",
+ "plugins.suggested_label": "Plugins sugeridos",
+ "plugins.no_plugins_yet": "Nenhum plugin configurado ainda.",
+ "plugins.enabled_label": "Ativado",
+ "plugins.open_label": "Abrir",
+ "plugins.path_label": "Caminho",
+ "plugins.scope_project": "Projeto",
+ "plugins.scope_global": "Global",
+ "plugins.refresh": "Atualizar",
+ "plugins.config": "Config",
+ "plugins.not_loaded": "Ainda não carregado",
+ "plugins.suggested": "Plugins sugeridos",
+ "plugins.hide_setup": "Ocultar configuração",
+ "plugins.setup": "Configurar",
+ "plugins.added": "Adicionado",
+ "plugins.add": "Adicionar",
+ "plugins.enabled": "Ativado",
+ "plugins.no_plugins": "Nenhum plugin configurado ainda.",
+ "plugins.add_label": "Adicionar plugin",
+ "plugins.placeholder": "opencode-wakatime",
+ "plugins.add_hint": "Adicione nomes de pacotes npm, ex: opencode-wakatime",
+
+ // ==================== Apps (MCP) ====================
+ "mcp.apps_title": "Apps",
+ "mcp.apps_subtitle": "Conecte suas ferramentas favoritas para que o OpenWork as use em seu nome.",
+ "mcp.app_connected": "app conectado",
+ "mcp.apps_connected": "apps conectados",
+ "mcp.title": "Apps",
+ "mcp.description": "Conecte suas ferramentas com um clique.",
+ "mcp.alpha_banner_title": "Os Apps estão em acesso antecipado enquanto refinamos a experiência.",
+ "mcp.alpha_banner_help": "Se quiser ajudar, abra um PR e inclua um vídeo curto mostrando o fluxo de login funcionando de ponta a ponta.",
+ "mcp.mcps_title": "Apps",
+ "mcp.connect_mcp_hint": "Conecte apps para expandir o que o OpenWork pode fazer.",
+ "mcp.finish_setup": "Quase lá",
+ "mcp.finish_setup_hint": "Toque em Ativar para terminar de conectar seu app.",
+ "mcp.activate_button": "Ativar",
+ "mcp.reload_banner_title": "Quase lá",
+ "mcp.reload_banner_description": "Toque em Ativar para terminar de conectar seu app.",
+ "mcp.reload_banner_description_blocked": "Uma tarefa está em execução. Pare-a primeiro e então ative.",
+ "mcp.reload_banner_blocked_hint": "Pare a tarefa em execução para ativar.",
+ "mcp.available_apps": "Apps disponíveis",
+ "mcp.one_click_connect": "Conectar com um clique",
+ "mcp.tap_to_connect": "Toque para conectar",
+ "mcp.connected_badge": "Conectado",
+ "mcp.your_apps": "Seus apps",
+ "mcp.last_synced": "Sincronizado",
+ "mcp.no_apps_yet": "Nenhum app conectado ainda",
+ "mcp.no_apps_hint": "Conecte um acima para começar.",
+ "mcp.quick_connect_title": "Apps disponíveis",
+ "mcp.oauth_only_label": "Um clique",
+ "mcp.connected_status": "Conectado",
+ "mcp.no_env_vars": "Nenhuma configuração adicional necessária.",
+ "mcp.connected_title": "Seus apps",
+ "mcp.from_opencode_json": "Da configuração",
+ "mcp.no_servers_yet": "Nenhum app conectado ainda.",
+ "mcp.edit_config_title": "Editar arquivo de configuração",
+ "mcp.edit_config_description": "Os apps são armazenados no arquivo de configuração do seu workspace.",
+ "mcp.docs_link": "Saiba mais",
+ "mcp.scope_project": "Este workspace",
+ "mcp.scope_global": "Todos os workers",
+ "mcp.config_label": "Config",
+ "mcp.config_file": "Arquivo de configuração",
+ "mcp.config_not_loaded": "Ainda não carregado",
+ "mcp.open_file_label": "Abrir arquivo",
+ "mcp.reveal_in_finder": "Mostrar no Finder",
+ "mcp.opening_label": "Abrindo...",
+ "mcp.file_not_found": "Arquivo de configuração ainda não criado",
+ "mcp.config_load_failed": "Não foi possível carregar o arquivo de configuração",
+ "mcp.open_file": "Abrir arquivo",
+ "mcp.pick_workspace_error": "Escolha primeiro uma pasta de workspace.",
+ "mcp.reveal_config_failed": "Não foi possível abrir o arquivo de configuração",
+ "mcp.alpha_warning": "Os Apps estão em acesso antecipado enquanto refinamos a experiência.",
+ "mcp.github_issue": "Ver issue #9510 no GitHub",
+ "mcp.contribution_guide": "Se quiser ajudar, abra um PR e inclua um vídeo curto mostrando o fluxo de login funcionando de ponta a ponta.",
+ "mcp.advanced_settings": "Configurações avançadas",
+ "mcp.advanced_settings_hint": "Edite arquivos de configuração e gerencie conexões manualmente.",
+ "mcp.hide_advanced": "Ocultar configurações avançadas",
+ "mcp.show_advanced": "Mostrar configurações avançadas",
+ "mcp.mcps_label": "Apps",
+ "mcp.mcps_description": "Conecte apps para expandir o que o OpenWork pode fazer.",
+ "mcp.configured": "configurado",
+ "mcp.updated": "Sincronizado",
+ "mcp.reload_required": "Ativação necessária",
+ "mcp.reload_description": "Ative para começar a usar a nova conexão.",
+ "mcp.reload_engine": "Ativar",
+ "mcp.quick_connect": "Apps disponíveis",
+ "mcp.oauth_only": "Um clique",
+ "mcp.connecting": "Conectando...",
+ "mcp.connect": "Conectar",
+ "mcp.connected": "Conectado",
+ "mcp.connected_label": "Conectado",
+ "mcp.no_env_required": "Nenhuma configuração adicional necessária.",
+ "mcp.config_source": "Da configuração",
+ "mcp.no_servers": "Nenhum app conectado ainda.",
+ "mcp.advanced": "Avançado",
+ "mcp.advanced_description": "Para conexões personalizadas.",
+ "mcp.hide": "Ocultar",
+ "mcp.show": "Mostrar",
+ "mcp.server_name": "Nome do app",
+ "mcp.server_name_placeholder": "github-copilot",
+ "mcp.server_url": "URL do servidor",
+ "mcp.server_url_placeholder": "https://api.githubcopilot.com/mcp/",
+ "mcp.oauth": "Entrar",
+ "mcp.api_key": "Chave de API",
+ "mcp.enabled": "Ativado",
+ "mcp.disabled": "Desativado",
+ "mcp.add_mcp": "Adicionar app",
+ "mcp.verify_connection": "Testar conexão",
+ "mcp.cli_guidance": "Comando no terminal (avançado)",
+ "mcp.config_locations": "A configuração pode ficar em opencode.json, opencode.jsonc ou .opencode/opencode.json.",
+ "mcp.app_details": "Detalhes do app",
+ "mcp.details_title": "Detalhes do app",
+ "mcp.select_app_hint": "Selecione um app para ver os detalhes.",
+ "mcp.select_server_hint": "Selecione um app para ver os detalhes.",
+ "mcp.connection_type": "Conexão",
+ "mcp.type_cloud": "Nuvem (entrar com sua conta)",
+ "mcp.type_local": "Local (roda neste dispositivo)",
+ "mcp.capabilities_label": "Recursos",
+ "mcp.cap_tools": "Ferramentas de IA",
+ "mcp.cap_signin": "Login na conta",
+ "mcp.tools_enabled_label": "Ferramentas de IA",
+ "mcp.oauth_ready_label": "Login na conta",
+ "mcp.usage_hint_text": "Mencione o nome do app no seu prompt para usar suas ferramentas.",
+ "mcp.issue_label": "Problema",
+ "mcp.technical_details": "Detalhes técnicos",
+ "mcp.next_steps_label": "O que fazer",
+ "mcp.reload_step": "Ative após conectar um novo app.",
+ "mcp.auth_step": "Entre quando solicitado.",
+ "mcp.connection_failed": "Problema de conexão, tente novamente",
+ "mcp.needs_auth": "Login necessário",
+ "mcp.register_client": "Configuração necessária",
+ "mcp.status_disabled": "Pausado",
+ "mcp.disconnected": "Offline",
+ "mcp.failed": "Problema",
+ "mcp.friendly_status_ready": "Pronto",
+ "mcp.friendly_status_needs_signin": "Login necessário",
+ "mcp.friendly_status_paused": "Pausado",
+ "mcp.friendly_status_offline": "Offline",
+ "mcp.friendly_status_issue": "Problema",
+ "mcp.host_mode_only": "Apps requerem o app desktop.",
+ "mcp.pick_workspace_first": "Escolha primeiro uma pasta de workspace.",
+ "mcp.desktop_required": "Apps requerem o app desktop.",
+ "mcp.connect_server_first": "Conecte-se ao servidor primeiro.",
+ "mcp.reload_required_after_add": "Ative para começar a usar o novo app.",
+ "mcp.connect_failed": "Não foi possível conectar. Tente novamente.",
+ "mcp.enter_name_and_url": "Digite um nome e URL para o app.",
+ "mcp.enter_url_first": "Digite uma URL primeiro.",
+ "mcp.use_debug_command": "Execute opencode mcp debug para solucionar problemas.",
+ "mcp.add_failed": "Não foi possível adicionar o app.",
+ "mcp.remove_app": "Remover",
+ "mcp.remove_failed": "Não foi possível remover o app.",
+ "mcp.remove_modal_title": "Remover app",
+ "mcp.remove_modal_message": "Tem certeza que deseja remover {server}? Você pode adicioná-lo de volta a qualquer momento.",
+
+ // Add MCP Modal
+ "mcp.add_modal_title": "Adicionar Servidor MCP",
+ "mcp.add_modal_subtitle": "Conecte um servidor MCP personalizado por URL ou comando local.",
+ "mcp.server_type": "Tipo",
+ "mcp.type_remote": "Remoto (URL)",
+ "mcp.type_local_cmd": "Local (comando)",
+ "mcp.server_command": "Comando",
+ "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking",
+ "mcp.server_command_hint": "O comando shell para iniciar o servidor.",
+ "mcp.oauth_optional_label": "Requer login OAuth",
+ "mcp.remote_workspace_url_hint": "Workers remotos conectam mais rápido com servidores MCP baseados em URL.",
+ "mcp.add_server_button": "Adicionar servidor",
+ "mcp.name_required": "Digite um nome para o servidor.",
+ "mcp.url_or_command_required": "Digite uma URL para servidores remotos ou um comando para servidores locais.",
+
+ "mcp.logout_label": "OAuth",
+ "mcp.logout_action": "Sair",
+ "mcp.logout_working": "Saindo...",
+ "mcp.logout_hint": "Remove as credenciais OAuth armazenadas. Você precisará entrar novamente.",
+ "mcp.login_action": "Entrar",
+ "mcp.login_hint": "Conecte sua conta para terminar de configurar este app.",
+ "mcp.login_unavailable": "Este app não suporta login pelo OpenWork.",
+ "mcp.logout_modal_title": "Sair deste app?",
+ "mcp.logout_modal_message": "Isso removerá as credenciais OAuth armazenadas para {server}. Você precisará entrar novamente para usar este app.",
+ "mcp.logout_success": "Saiu de {server}.",
+ "mcp.logout_failed": "Falha ao sair.",
+
+ // MCP Auth Modal
+ "mcp.auth.open_browser_signin": "Abriremos seu navegador para concluir o login.",
+ "mcp.auth.connect_server": "Conectar {server}",
+ "mcp.auth.already_connected": "Já Conectado",
+ "mcp.auth.already_connected_description": "{server} já está autenticado e pronto para uso.",
+ "mcp.auth.configured_previously": "O MCP pode ter sido configurado globalmente ou em uma sessão anterior. Você pode fechar este modal e começar a usar as ferramentas MCP imediatamente.",
+ "mcp.auth.reload_engine_retry": "Aplicar alterações e tentar novamente",
+ "mcp.auth.retry_now": "Tentar Agora",
+ "mcp.auth.retry": "Tentar novamente",
+ "mcp.auth.reload_failed": "Falha ao recarregar o worker antes do login.",
+ "mcp.auth.applying_changes_title": "Aplicando alterações antes do login",
+ "mcp.auth.applying_changes_body": "Estamos reiniciando o worker para que o novo MCP esteja pronto para autenticar.",
+ "mcp.auth.waiting_for_conversation_title": "Aguardando conversa ser concluída",
+ "mcp.auth.waiting_for_conversation_body": "Vamos redirecioná-lo para autenticar assim que possível.",
+ "mcp.auth.waiting_for_session": "Aguardando {sessão} terminar",
+ "mcp.auth.force_stop": "Forçar parada",
+ "mcp.auth.force_stopping": "Parando...",
+ "mcp.auth.reload_before_oauth": "Recarregue o engine para concluir a configuração deste MCP antes de iniciar o OAuth.",
+ "mcp.auth.reload_notice": "Para isso ter efeito, o OpenWork precisa reiniciar o serviço worker. Isso pode interromper uma sessão em andamento.",
+ "mcp.auth.reload_blocked": "O recarregamento está pausado enquanto uma sessão está em execução. Pare a execução para concluir a configuração.",
+ "mcp.auth.reload_remote_confirm": "Para isso ter efeito, o OpenWork precisa reiniciar o serviço worker. Isso pode parar sua sessão em andamento. Continuar?",
+ "mcp.auth.reload_needed": "Conclua a configuração recarregando o engine e tente conectar novamente.",
+ "mcp.auth.manual_finish_title": "Servidor remoto?",
+ "mcp.auth.manual_finish_hint": "Cole a URL de callback (localhost:19876) ou apenas o código para concluir a conexão.",
+ "mcp.auth.callback_label": "URL de callback ou código",
+ "mcp.auth.callback_placeholder": "http://127.0.0.1:19876/mcp/oauth/callback?code=...",
+ "mcp.auth.complete_connection": "Concluir conexão",
+ "mcp.auth.callback_invalid": "Cole a URL de callback ou o parâmetro de código para concluir o OAuth.",
+ "mcp.auth.port_forward_hint": "Dica: encaminhe a porta de callback se necessário: ssh -L 19876:127.0.0.1:19876 user@host",
+ "mcp.auth.step1_title": "Abrindo seu navegador",
+ "mcp.auth.step1_description": "Iniciaremos o fluxo de login do {server} automaticamente.",
+ "mcp.auth.step2_title": "Autorizar o OpenWork",
+ "mcp.auth.step2_description": "Entre e aprove o acesso quando solicitado.",
+ "mcp.auth.step3_title": "Volte aqui quando terminar",
+ "mcp.auth.step3_description": "Concluiremos a conexão assim que a autorização for completada.",
+ "mcp.auth.waiting_authorization": "Aguardando a autorização ser concluída no seu navegador...",
+ "mcp.auth.follow_browser_steps": "Siga os passos de autorização no navegador.",
+ "mcp.auth.reopen_browser_link": "Clique aqui para reabrir o navegador",
+ "mcp.auth.done": "Concluído",
+ "mcp.auth.cancel": "Cancelar",
+ "mcp.auth.im_done": "Terminei",
+ "mcp.auth.client_registration_required": "O registro do cliente é necessário antes de continuar com o OAuth.",
+ "mcp.auth.server_disabled": "Este servidor MCP está desativado. Ative-o e tente novamente.",
+ "mcp.auth.oauth_failed": "Falha na autenticação OAuth.",
+ "mcp.auth.invalid_refresh_token": "O token de atualização OAuth é inválido ou expirou. Reautorize para continuar.",
+ "mcp.auth.reauth_action": "Reautorizar OAuth",
+ "mcp.auth.reauth_running": "Reautorizando...",
+ "mcp.auth.reauth_failed": "Falha na reautorização.",
+ "mcp.auth.reauth_cli_hint": "Execute: opencode mcp auth {server}",
+ "mcp.auth.reauth_remote_hint": "Reautorize a partir da máquina que executa este worker.",
+ "mcp.auth.authorization_still_required": "A autorização ainda é necessária. Tente novamente para reiniciar o fluxo.",
+ "mcp.auth.oauth_not_supported_hint": "Isso pode significar:\n• O servidor MCP não anuncia capacidades OAuth\n• O engine precisa recarregar para descobrir as capacidades do servidor\n• Tente: opencode mcp auth {server} pela CLI",
+ "mcp.auth.try_reload_engine": "{message}. Tente recarregar o engine primeiro.",
+ "mcp.auth.failed_to_start_oauth": "Falha ao iniciar fluxo OAuth",
+ "mcp.auth.oauth_completed_reload": "OAuth concluído. Recarregue o engine para ativar o MCP.",
+
+ // ==================== Settings ====================
+ "settings.title": "Configurações",
+ "settings.connection": "Conexão",
+ "settings.engine_source": "Fonte do engine",
+ "settings.from_path": "Via PATH",
+ "settings.from_sidecar": "Sidecar integrado",
+ "settings.engine_source_description": "PATH usa o OpenCode instalado (padrão). Sidecar usará um binário integrado quando disponível.",
+ "settings.sidecar_unsupported": "Sidecar disponível no Windows",
+ "settings.sidecar_unavailable_detail": "Sidecar é integrado quando disponível.",
+ "settings.model": "Modelo",
+ "settings.model_description": "Padrões e controles de raciocínio para execuções.",
+ "settings.change": "Alterar",
+ "settings.engine_path": "PATH",
+ "settings.engine_sidecar": "Sidecar",
+ "settings.thinking": "Raciocínio",
+ "settings.thinking_description": "Mostrar partes de raciocínio (apenas no modo Desenvolvedor).",
+ "settings.on": "Ativado",
+ "settings.off": "Desativado",
+ "settings.model_variant": "Variante do modelo",
+ "settings.edit": "Editar",
+ "settings.default_model": "Modelo padrão",
+ "settings.session_model": "Modelo",
+ "settings.model_description_default": "Escolha entre seus provedores configurados. Esta seleção será usada para novas sessões.",
+ "settings.model_description_session": "Escolha entre seus provedores configurados. Esta seleção se aplica à sua próxima mensagem.",
+ "settings.search_models": "Buscar modelos…",
+ "settings.showing_models": "Exibindo {count} de {total}",
+ "settings.model_variant_prompt": "Variante do modelo (específica do provedor, ex: high/max/minimal). Deixe em branco para limpar.",
+ "settings.model_fallback": "Alternativa",
+ "settings.model_default": "Padrão",
+ "settings.model_free": "Gratuito",
+ "settings.model_reasoning": "Raciocínio",
+ "settings.done": "Concluído",
+ "settings.updates": "Atualizações",
+ "settings.updates_description": "Manter o OpenWork atualizado.",
+ "settings.automatic_checks": "Verificações automáticas",
+ "settings.automatic_checks_description": "Uma vez por dia (silencioso)",
+ "settings.update_checking": "Verificando...",
+ "settings.update_available": "Atualização disponível: v",
+ "settings.update_downloading": "Baixando...",
+ "settings.update_ready": "Pronto para instalar: v",
+ "settings.update_error": "Falha na verificação de atualização",
+ "settings.update_uptodate": "Atualizado",
+ "settings.last_checked": "Última verificação",
+ "settings.published": "Publicado",
+ "settings.check_update": "Verificar",
+ "settings.install_restart": "Instalar e Reiniciar",
+ "settings.update_not_supported": "Atualizações não são suportadas neste ambiente.",
+ "settings.update_desktop_only": "Atualizações estão disponíveis apenas no app desktop.",
+ "settings.startup": "Inicialização",
+ "settings.mode_label": "modo",
+ "settings.switch_mode": "Alternar",
+ "settings.reset_startup": "Redefinir modo de inicialização padrão",
+ "settings.reset_startup_description": "Apaga sua preferência salva e exibe a seleção de modo na próxima inicialização.",
+ "settings.advanced": "Avançado",
+ "settings.advanced_description": "Redefinir estado local do OpenWork para retestar o onboarding.",
+ "settings.reset_onboarding": "Redefinir onboarding",
+ "settings.reset_onboarding_description": "Apaga as preferências do OpenWork e reinicia o app.",
+ "settings.reset_app_data": "Redefinir dados do app",
+ "settings.reset_app_data_description": "Mais agressivo. Apaga cache + dados do OpenWork.",
+ "settings.reset": "Redefinir",
+ "settings.requires_typing": "Requer digitação",
+ "settings.will_restart": "e reiniciará o app.",
+ "settings.reset_onboarding_title": "Redefinir onboarding",
+ "settings.reset_app_data_title": "Redefinir dados do app",
+ "settings.reset_confirmation_hint": "Digite RESET para confirmar. O OpenWork será reiniciado.",
+ "settings.reset_onboarding_warning": "Apaga preferências locais e marcadores de onboarding do workspace no OpenWork.",
+ "settings.reset_app_data_warning": "Apaga cache e dados do OpenWork neste dispositivo.",
+ "settings.reset_stop_active_runs": "Pare as execuções ativas antes de redefinir.",
+ "settings.reset_confirmation_label": "Confirmação",
+ "settings.reset_confirmation_placeholder": "Digite RESET",
+ "settings.reset_cancel": "Cancelar",
+ "settings.reset_confirm_button": "Redefinir e Reiniciar",
+ "settings.developer": "Desenvolvedor",
+ "settings.opencode_cache": "Cache do OpenCode",
+ "settings.opencode_cache_description": "Repara dados em cache usados para iniciar o engine. Seguro de executar.",
+ "settings.repair_cache": "Reparar cache",
+ "settings.repairing_cache": "Reparando cache",
+ "settings.cache_repair_requires_desktop": "O reparo de cache requer o app desktop",
+ "settings.pending_permissions": "Permissões pendentes",
+ "settings.recent_events": "Eventos recentes",
+ "settings.notion_connected": "Conectado",
+ "settings.reload_required": "Recarregamento necessário",
+ "settings.connection_failed": "Falha na conexão",
+ "settings.notion_not_connected": "Não conectado",
+ "settings.show_thinking": "Mostrar raciocínio",
+ "settings.update": "Atualizar",
+ "settings.about": "Sobre",
+ "settings.version": "Versão",
+ "settings.check_for_updates": "Verificar atualizações",
+ "settings.download_update": "Baixar atualização",
+ "settings.install_update": "Instalar atualização e reiniciar",
+ "settings.enable_developer_mode": "Ativar Modo Desenvolvedor",
+ "settings.disable_developer_mode": "Desativar Modo Desenvolvedor",
+ "settings.stop_engine": "Parar engine",
+ "settings.disconnect": "Desconectar",
+ "settings.language": "Idioma",
+ "settings.language.description": "Escolha seu idioma preferido",
+ "settings.connection_title": "Conexão",
+ "settings.engine_source_label": "Fonte do engine",
+ "settings.engine_source_hint": "PATH usa o OpenCode instalado (padrão). Sidecar usará um binário integrado quando disponível.",
+ "settings.sidecar_unavailable": "Sidecar é integrado quando disponível.",
+ "settings.model_title": "Modelo",
+ "settings.model_hint": "Padrões e controles de raciocínio para execuções.",
+ "settings.thinking_label": "Raciocínio",
+ "settings.thinking_hint": "Mostrar partes de raciocínio (apenas no modo Desenvolvedor).",
+ "settings.model_variant_label": "Variante do modelo",
+ "settings.appearance_title": "Aparência",
+ "settings.appearance_hint": "Seguir o sistema ou forçar modo claro/escuro.",
+ "settings.theme_system": "Sistema",
+ "settings.theme_light": "Claro",
+ "settings.theme_dark": "Escuro",
+ "settings.theme_system_hint": "O modo sistema segue automaticamente a preferência do seu SO.",
+ "settings.updates_title": "Atualizações",
+ "settings.updates_hint": "Manter o OpenWork atualizado.",
+ "settings.automatic_checks_label": "Verificações automáticas",
+ "settings.automatic_checks_hint": "Uma vez por dia (silencioso)",
+ "settings.last_checked_time": "Última verificação {time}",
+ "settings.published_date": "Publicado em {date}",
+ "settings.update_not_supported_hint": "Atualizações não são suportadas neste ambiente.",
+ "settings.update_desktop_only_hint": "Atualizações estão disponíveis apenas no app desktop.",
+ "settings.startup_title": "Inicialização",
+ "settings.mode_suffix": "modo",
+ "settings.reset_startup_label": "Redefinir modo de inicialização padrão",
+ "settings.reset_startup_hint": "Apaga sua preferência salva e exibe a seleção de modo na próxima inicialização.",
+ "settings.advanced_title": "Avançado",
+ "settings.advanced_hint": "Redefinir estado local do OpenWork para retestar o onboarding.",
+ "settings.reset_onboarding_label": "Redefinir onboarding",
+ "settings.reset_onboarding_hint": "Apaga as preferências do OpenWork e reinicia o app.",
+ "settings.reset_app_data_label": "Redefinir dados do app",
+ "settings.reset_app_data_hint": "Mais agressivo. Apaga cache + dados do OpenWork.",
+ "settings.reset_requires_hint": "Requer digitar RESET e reiniciará o app.",
+ "settings.developer_title": "Desenvolvedor",
+ "settings.opencode_cache_label": "Cache do OpenCode",
+ "settings.opencode_cache_hint": "Repara dados em cache usados para iniciar o engine. Seguro de executar.",
+ "settings.migration_recovery_label": "Recuperação de migração",
+ "settings.migration_recovery_hint": "Use isso se a inicialização local falhar ao migrar de dados JSON legados.",
+ "settings.fix_migration": "Corrigir migração",
+ "settings.fixing_migration": "Corrigindo migração...",
+ "settings.migration_repair_requires_desktop": "O reparo de migração requer o app desktop",
+ "settings.pending_permissions_label": "Permissões pendentes",
+ "settings.recent_events_label": "Eventos recentes",
+ "settings.stop_active_runs_hint": "Pare as execuções ativas para atualizar",
+ "settings.stop_active_runs_reset_hint": "Pare as execuções ativas para redefinir",
+ "settings.stop_runs_to_update": "Pare as execuções ativas para atualizar",
+ "settings.stop_runs_to_reset": "Pare as execuções ativas para redefinir",
+ "settings.updates_not_supported": "Atualizações não são suportadas neste ambiente.",
+ "settings.updates_desktop_only": "Atualizações estão disponíveis apenas no app desktop.",
+
+ // ==================== Reload ====================
+ "reload.toast_title": "Atualizações disponíveis",
+ "reload.toast_description": "Recarregue o workspace para aplicar as alterações de configuração.",
+ "reload.toast_warning": "Para todas as tarefas ativas.",
+ "reload.toast_warning_active": "Recarregar para {count} tarefa(s) ativa(s).",
+ "reload.toast_reload": "Recarregar",
+ "reload.toast_reload_stopped": "Recarregar e Parar Tarefas",
+ "reload.toast_reloading": "Recarregando...",
+ "reload.toast_dismiss": "Depois",
+ "reload.toast_blocked_host": "O recarregamento está disponível apenas para workers locais.",
+ "reload.toast_blocked_connect": "Conecte-se a este workspace para recarregar.",
+ "reload.toast_blocked_runs": "Aguardando as tarefas ativas serem concluídas antes de recarregar.",
+
+ // ==================== Onboarding ====================
+ "onboarding.starting_host": "Iniciando servidor OpenWork...",
+ "onboarding.searching_host": "Conectando ao servidor OpenWork...",
+ "onboarding.getting_ready": "Preparando tudo",
+ "onboarding.verifying": "Verificando handshake seguro",
+ "onboarding.create_first_workspace": "Crie seu primeiro workspace",
+ "onboarding.create_workspace": "Criar um workspace",
+ "onboarding.workspace_description": "Escolha uma pasta e um predefinição para configurar seu workspace.",
+ "onboarding.start": "Iniciar OpenWork",
+ "onboarding.back": "Voltar",
+ "onboarding.advanced_settings": "Configurações avançadas",
+ "onboarding.opencode_engine": "Engine OpenCode",
+ "onboarding.refresh": "Atualizar",
+ "onboarding.checking_cli": "Verificando OpenCode CLI...",
+ "onboarding.cli_not_found": "OpenCode CLI não encontrado.",
+ "onboarding.cli_needs_update": "O OpenCode CLI precisa de uma atualização para o serve.",
+ "onboarding.opencode": "OpenCode",
+ "onboarding.cli_ready": "OpenCode CLI pronto.",
+ "onboarding.cli_version": "OpenCode {version}",
+ "onboarding.windows_install_instruction": "Instale o OpenCode para Windows e reinicie o OpenWork. Certifique-se de que opencode.exe está no PATH.",
+ "onboarding.install_instruction": "Instale o OpenCode para ativar o servidor local (sem terminal necessário).",
+ "onboarding.install": "Instalar OpenCode",
+ "onboarding.recheck": "Verificar novamente",
+ "onboarding.ready_message": "O OpenCode está pronto para iniciar o servidor local.",
+ "onboarding.resolved_path": "Caminho resolvido",
+ "onboarding.version": "Versão",
+ "onboarding.search_notes": "Notas de busca",
+ "onboarding.serve_help": "saída de serve --help",
+ "onboarding.workspace_folder_label": "Um workspace é uma pasta com suas próprias skills, plugins e comandos.",
+ "onboarding.theme_label": "Tema",
+ "onboarding.theme_current": "Atual: {mode}",
+ "onboarding.theme_system": "Sistema",
+ "onboarding.theme_light": "Claro",
+ "onboarding.theme_dark": "Escuro",
+ "onboarding.access_label": "Acesso",
+ "onboarding.folders_allowed": "{count} pasta(s) permitida(s)",
+ "onboarding.manage_access_hint": "Você pode gerenciar o acesso nas configurações avançadas.",
+ "onboarding.open_settings_hint": "Precisa de opções de engine ou acesso? Abra as Configurações.",
+ "onboarding.open_settings": "Abrir Configurações",
+ "onboarding.add_folder_path": "Adicionar caminho de pasta",
+ "onboarding.pick": "Selecionar",
+ "onboarding.add": "Adicionar",
+ "onboarding.remove": "Remover",
+ "onboarding.cli_label": "OpenCode CLI",
+ "onboarding.cli_checking": "Verificando instalação...",
+ "onboarding.cli_not_found_hint": "Não encontrado. Instale para executar o servidor local.",
+ "onboarding.cli_version_installed": "Instalado",
+ "onboarding.cli_recheck": "Verificar novamente",
+ "onboarding.cli_install_commands": "Instale o OpenCode com um dos comandos abaixo e reinicie o OpenWork.",
+ "onboarding.show_search_notes": "Mostrar notas de busca",
+ "onboarding.last_checked": "Última verificação {time}",
+ "onboarding.fix_migration": "Corrigir migração",
+ "onboarding.fixing_migration": "Corrigindo migração...",
+ "onboarding.fix_migration_hint": "Para o engine local, executa opencode db migrate e tenta inicializar novamente.",
+ "onboarding.server_url_placeholder": "http://localhost:8088",
+ "onboarding.directory_placeholder": "meu-projeto",
+ "onboarding.connect_host": "Conectar ao servidor",
+ "onboarding.connect_description": "Conectar-se a um servidor OpenCode existente (LAN ou túnel).",
+ "onboarding.server_url": "URL do servidor",
+ "onboarding.directory": "Diretório (opcional)",
+ "onboarding.directory_hint": "Use se o servidor executar múltiplos workers.",
+ "onboarding.connect": "Conectar",
+ "onboarding.remote_workspace_title": "Conectar ao servidor OpenWork",
+ "onboarding.remote_workspace_description": "Conecte-se a um servidor OpenWork para acessar um workspace de qualquer lugar.",
+ "onboarding.remote_workspace_action": "Conectar",
+ "onboarding.remote_workspace_card_title": "Conectar um workspace remoto",
+ "onboarding.remote_workspace_card_description": "Conecte-se a um servidor OpenWork para acessar um workspace compartilhado.",
+ "onboarding.advanced_openwork_host": "Servidor OpenWork",
+ "onboarding.advanced_openwork_hint": "Use uma URL de servidor e token de acesso para acesso compartilhado.",
+ "onboarding.advanced_opencode_direct": "Avançado: OpenCode direto",
+ "onboarding.advanced_opencode_hint": "Conecte diretamente a um engine OpenCode quando nenhum servidor estiver disponível.",
+ "onboarding.welcome_title": "Como você quer executar o OpenWork hoje?",
+ "onboarding.run_local": "Executar localmente",
+ "onboarding.run_local_description": "O OpenWork executa o OpenCode localmente e mantém seu trabalho privado.",
+ "onboarding.engine_running": "Engine já em execução",
+ "onboarding.attach_description": "Conectar à sessão existente neste dispositivo.",
+ "onboarding.attach": "Conectar",
+ "onboarding.remember_choice": "Lembrar minha escolha para a próxima vez",
+ "onboarding.client_mode": "Conectar como Cliente (Emparelhamento Remoto)",
+ "onboarding.default_workspace_path": "~/OpenWork/Worker",
+ "onboarding.authorize_folder": "Autorizar pasta",
+ "onboarding.choose_workspace_folder": "Escolher pasta do workspace",
+
+ // ==================== Common ====================
+ "common.alpha": "Alpha",
+ "common.change": "Alterar",
+ "common.refresh": "Atualizar",
+ "common.new": "Novo",
+ "common.install": "Instalar",
+ "common.delete": "Excluir",
+ "common.edit": "Editar",
+ "common.save": "Salvar",
+ "common.cancel": "Cancelar",
+ "common.close": "Fechar",
+ "common.open": "Abrir",
+ "common.show": "Mostrar",
+ "common.hide": "Ocultar",
+ "common.path": "Caminho",
+ "common.choose": "Escolher",
+ "common.retry": "Tentar novamente",
+ "common.untitled": "Sem título",
+ "common.default_parens": "(padrão)",
+ "common.on": "Ativado",
+ "common.off": "Desativado",
+
+ // ==================== Status ====================
+ "status.connected": "Conectado",
+ "status.disconnected": "Desconectado",
+ "status.idle": "Ocioso",
+ "status.busy": "Ocupado",
+ "status.running": "Em execução",
+ "status.live": "Ao vivo",
+ "status.connecting": "Conectando",
+ "status.creating_workspace": "Criando workspace",
+ "status.deleting_command": "Excluindo comando",
+ "status.saving_workspace_command": "Salvando comando do workspace",
+ "status.saving_command": "Salvando comando",
+ "status.loading_session": "Carregando sessão",
+ "status.creating_task": "Criando nova tarefa",
+ "status.starting_engine": "Iniciando engine",
+ "status.reloading_engine": "Recarregando engine",
+ "status.restarting_engine": "Reiniciando engine",
+ "status.installing_opencode": "Instalando OpenCode",
+ "status.repairing_migration": "Reparando migração",
+ "status.disconnecting": "Desconectando",
+
+ // ==================== Workspace Switching ====================
+ "workspace.switching_title": "Abrindo {name}",
+ "workspace.switching_title_unknown": "Abrindo workspace",
+ "workspace.switching_subtitle": "Vamos trazer seu trabalho recente de volta.",
+ "workspace.switching_status_preparing": "Preparando tudo",
+ "workspace.switching_status_connecting": "Verificando sua conexão",
+ "workspace.switching_status_loading": "Carregando tarefas recentes",
+ "workspace.switching_status_almost": "Quase lá",
+
+ "app.connection_lost": "Conexão com o servidor perdida. Por favor, recarregue.",
+ "app.unknown_error": "Erro desconhecido",
+ "app.error.tauri_required": "Esta ação requer o runtime do app Tauri.",
+ "app.error.choose_folder": "Escolha uma pasta para continuar.",
+ "app.error.pick_workspace_folder": "Selecione primeiro uma pasta de workspace.",
+ "app.error.remote_base_url_required": "Adicione uma URL de servidor para continuar.",
+ "app.error.host_requires_local": "Selecione um workspace local para iniciar o engine.",
+ "app.error.sidecar_unsupported_windows": "O OpenCode Sidecar é integrado no Windows quando disponível. Usando PATH como alternativa.",
+ "app.error.install_failed": "Falha na instalação do OpenCode. Veja os logs acima.",
+ "app.migration.desktop_required": "O reparo de migração requer o app desktop.",
+ "app.migration.local_only": "O reparo de migração está disponível apenas para workers locais.",
+ "app.migration.workspace_required": "Selecione uma pasta de workspace local antes de reparar a migração.",
+ "app.migration.unsupported": "Este binário do OpenCode não suporta `opencode db migrate`. Atualize o OpenCode para >=1.2.6 ou mude para o engine integrado.",
+ "app.migration.failed": "Falha na migração do OpenCode.",
+ "app.migration.restart_failed": "Migração concluída, mas o OpenWork não conseguiu reiniciar o engine local.",
+ "app.migration.success": "Migração reparada. A inicialização local foi tentada novamente.",
+ "app.error.command_name_template_required": "O nome e as instruções do comando são obrigatórios.",
+ "app.error.workspace_commands_desktop": "Comandos requerem o app desktop.",
+ "app.error.command_scope_unknown": "Este comando não pode ser gerenciado neste modo.",
+} as const;
\ No newline at end of file
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 23ddfc77..3afd1faa 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,8 +1,8 @@
{
"name": "@openwork/desktop",
"private": true,
- "version": "0.11.182",
- "opencodeRouterVersion": "0.11.182",
+ "version": "0.11.186",
+ "opencodeRouterVersion": "0.11.186",
"type": "module",
"scripts": {
"dev": "OPENWORK_DEV_MODE=1 OPENWORK_DATA_DIR=\"$HOME/.openwork/openwork-orchestrator-dev\" tauri dev --config src-tauri/tauri.dev.conf.json --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"",
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index 03968d4e..8c4d9a34 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -1017,9 +1017,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
-version = "3.0.7"
+version = "3.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f"
+checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
dependencies = [
"cc",
"memchr",
@@ -2795,7 +2795,7 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openwork"
-version = "0.11.182"
+version = "0.11.186"
dependencies = [
"gethostname",
"json5",
@@ -3332,7 +3332,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
- "toml_edit 0.25.5+spec-1.1.0",
+ "toml_edit 0.25.8+spec-1.1.0",
]
[[package]]
@@ -4147,9 +4147,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
-version = "1.0.4"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
+checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
dependencies = [
"serde_core",
]
@@ -5242,7 +5242,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.13.0",
"serde_core",
- "serde_spanned 1.0.4",
+ "serde_spanned 1.1.0",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
@@ -5269,9 +5269,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
-version = "1.0.1+spec-1.1.0"
+version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
+checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
dependencies = [
"serde_core",
]
@@ -5302,30 +5302,30 @@ dependencies = [
[[package]]
name = "toml_edit"
-version = "0.25.5+spec-1.1.0"
+version = "0.25.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
+checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
dependencies = [
"indexmap 2.13.0",
- "toml_datetime 1.0.1+spec-1.1.0",
+ "toml_datetime 1.1.0+spec-1.1.0",
"toml_parser",
"winnow 1.0.0",
]
[[package]]
name = "toml_parser"
-version = "1.0.10+spec-1.1.0"
+version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
+checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
dependencies = [
"winnow 1.0.0",
]
[[package]]
name = "toml_writer"
-version = "1.0.7+spec-1.1.0"
+version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
+checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
[[package]]
name = "tower"
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index ddade9e9..0173416f 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "openwork"
-version = "0.11.182"
+version = "0.11.186"
description = "OpenWork"
authors = ["Different AI"]
edition = "2021"
diff --git a/apps/desktop/src-tauri/src/commands/engine.rs b/apps/desktop/src-tauri/src/commands/engine.rs
index 8aaffeec..f0f66f42 100644
--- a/apps/desktop/src-tauri/src/commands/engine.rs
+++ b/apps/desktop/src-tauri/src/commands/engine.rs
@@ -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,
@@ -90,6 +92,22 @@ struct OutputState {
exit_code: Option,
}
+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,
@@ -186,6 +204,7 @@ pub fn engine_restart(
openwork_manager: State,
opencode_router_manager: State,
opencode_enable_exa: Option,
+ openwork_remote_access: Option,
) -> Result {
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,
opencode_bin_path: Option,
opencode_enable_exa: Option,
+ openwork_remote_access: Option,
runtime: Option,
workspace_paths: Option>,
) -> Result {
@@ -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));
}
diff --git a/apps/desktop/src-tauri/src/commands/openwork_server.rs b/apps/desktop/src-tauri/src/commands/openwork_server.rs
index a8e74c10..cead1ebd 100644
--- a/apps/desktop/src-tauri/src/commands/openwork_server.rs
+++ b/apps/desktop/src-tauri/src/commands/openwork_server.rs
@@ -21,6 +21,7 @@ pub fn openwork_server_restart(
manager: State,
engine_manager: State,
opencode_router_manager: State,
+ remote_access_enabled: Option,
) -> Result {
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),
)
}
diff --git a/apps/desktop/src-tauri/src/commands/orchestrator.rs b/apps/desktop/src-tauri/src/commands/orchestrator.rs
index a70ed27b..347b2f47 100644
--- a/apps/desktop/src-tauri/src/commands/orchestrator.rs
+++ b/apps/desktop/src-tauri/src/commands/orchestrator.rs
@@ -850,12 +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(),
diff --git a/apps/desktop/src-tauri/src/commands/workspace.rs b/apps/desktop/src-tauri/src/commands/workspace.rs
index 43c4a793..9b2432c0 100644
--- a/apps/desktop/src-tauri/src/commands/workspace.rs
+++ b/apps/desktop/src-tauri/src/commands/workspace.rs
@@ -8,8 +8,8 @@ use crate::types::{
};
use crate::workspace::files::ensure_workspace_files;
use crate::workspace::state::{
- load_workspace_state, save_workspace_state, stable_workspace_id,
- stable_workspace_id_for_openwork, stable_workspace_id_for_remote,
+ load_workspace_state, normalize_local_workspace_path, save_workspace_state,
+ stable_workspace_id, stable_workspace_id_for_openwork, stable_workspace_id_for_remote,
};
use crate::workspace::watch::{update_workspace_watch, WorkspaceWatchState};
use serde::Serialize;
@@ -161,7 +161,7 @@ pub fn workspace_create(
watch_state: State,
) -> Result {
println!("[workspace] create local request");
- let folder = folder_path.trim().to_string();
+ let mut folder = folder_path.trim().to_string();
if folder.is_empty() {
return Err("folderPath is required".to_string());
}
@@ -179,6 +179,7 @@ pub fn workspace_create(
};
fs::create_dir_all(&folder).map_err(|e| format!("Failed to create workspace folder: {e}"))?;
+ folder = normalize_local_workspace_path(&folder);
let id = stable_workspace_id(&folder);
@@ -881,6 +882,7 @@ pub fn workspace_import_config(
.trim()
.to_string();
+ let target_dir = normalize_local_workspace_path(&target_dir);
let id = stable_workspace_id(&target_dir);
let mut state = load_workspace_state(&app)?;
diff --git a/apps/desktop/src-tauri/src/openwork_server/manager.rs b/apps/desktop/src-tauri/src/openwork_server/manager.rs
index f7ac607a..9f725b1c 100644
--- a/apps/desktop/src-tauri/src/openwork_server/manager.rs
+++ b/apps/desktop/src-tauri/src/openwork_server/manager.rs
@@ -13,6 +13,7 @@ pub struct OpenworkServerManager {
pub struct OpenworkServerState {
pub child: Option,
pub child_exited: bool,
+ pub remote_access_enabled: bool,
pub host: Option,
pub port: Option,
pub base_url: Option,
@@ -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;
diff --git a/apps/desktop/src-tauri/src/openwork_server/mod.rs b/apps/desktop/src-tauri/src/openwork_server/mod.rs
index 0dd8887f..c3cca552 100644
--- a/apps/desktop/src-tauri/src/openwork_server/mod.rs
+++ b/apps/desktop/src-tauri/src/openwork_server/mod.rs
@@ -198,11 +198,6 @@ fn build_urls(port: u16) -> (Option, Option, Option) {
(connect_url, mdns_url, lan_url)
}
-pub fn resolve_connect_url(port: u16) -> Option {
- 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,
+ remote_access_enabled: bool,
) -> Result {
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;
diff --git a/apps/desktop/src-tauri/src/openwork_server/spawn.rs b/apps/desktop/src-tauri/src/openwork_server/spawn.rs
index 05844b1b..167d659e 100644
--- a/apps/desktop/src-tauri/src/openwork_server/spawn.rs
+++ b/apps/desktop/src-tauri/src/openwork_server/spawn.rs
@@ -8,11 +8,11 @@ use tauri_plugin_shell::ShellExt;
const DEFAULT_OPENWORK_PORT: u16 = 8787;
-pub fn resolve_openwork_port() -> Result {
- if TcpListener::bind(("0.0.0.0", DEFAULT_OPENWORK_PORT)).is_ok() {
+pub fn resolve_openwork_port(host: &str) -> Result {
+ 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)
}
diff --git a/apps/desktop/src-tauri/src/orchestrator/mod.rs b/apps/desktop/src-tauri/src/orchestrator/mod.rs
index 2aea266b..51f123b4 100644
--- a/apps/desktop/src-tauri/src/orchestrator/mod.rs
+++ b/apps/desktop/src-tauri/src/orchestrator/mod.rs
@@ -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");
}
diff --git a/apps/desktop/src-tauri/src/types.rs b/apps/desktop/src-tauri/src/types.rs
index e91ca3ad..51d47b6e 100644
--- a/apps/desktop/src-tauri/src/types.rs
+++ b/apps/desktop/src-tauri/src/types.rs
@@ -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,
pub port: Option,
pub base_url: Option,
diff --git a/apps/desktop/src-tauri/src/workspace/state.rs b/apps/desktop/src-tauri/src/workspace/state.rs
index 32ab0feb..ceef86c2 100644
--- a/apps/desktop/src-tauri/src/workspace/state.rs
+++ b/apps/desktop/src-tauri/src/workspace/state.rs
@@ -4,6 +4,7 @@ use std::path::PathBuf;
use sha2::{Digest, Sha256};
use tauri::Manager;
+use crate::paths::home_dir;
use crate::types::{WorkspaceState, WorkspaceType, WORKSPACE_STATE_VERSION};
pub fn stable_workspace_id(path: &str) -> String {
@@ -12,6 +13,29 @@ pub fn stable_workspace_id(path: &str) -> String {
format!("ws_{}", &hex[..12])
}
+pub fn normalize_local_workspace_path(path: &str) -> String {
+ let trimmed = path.trim();
+ if trimmed.is_empty() {
+ return String::new();
+ }
+
+ let expanded = if trimmed == "~" {
+ home_dir().unwrap_or_else(|| PathBuf::from(trimmed))
+ } else if trimmed.starts_with("~/") || trimmed.starts_with("~\\") {
+ if let Some(home) = home_dir() {
+ let suffix = trimmed[2..].trim_start_matches(['/', '\\']);
+ home.join(suffix)
+ } else {
+ PathBuf::from(trimmed)
+ }
+ } else {
+ PathBuf::from(trimmed)
+ };
+
+ let normalized = fs::canonicalize(&expanded).unwrap_or(expanded);
+ normalized.to_string_lossy().to_string()
+}
+
pub fn openwork_state_paths(app: &tauri::AppHandle) -> Result<(PathBuf, PathBuf), String> {
let data_dir = app
.path()
@@ -36,7 +60,13 @@ pub fn load_workspace_state(app: &tauri::AppHandle) -> Result stable_workspace_id(&workspace.path),
+ WorkspaceType::Local => {
+ let normalized = normalize_local_workspace_path(&workspace.path);
+ if !normalized.is_empty() {
+ workspace.path = normalized;
+ }
+ stable_workspace_id(&workspace.path)
+ }
WorkspaceType::Remote => {
if workspace.remote_type == Some(crate::types::RemoteType::Openwork) {
stable_workspace_id_for_openwork(
@@ -108,3 +138,43 @@ pub fn stable_workspace_id_for_openwork(host_url: &str, workspace_id: Option<&st
}
stable_workspace_id(&key)
}
+
+#[cfg(test)]
+mod tests {
+ use super::{normalize_local_workspace_path, stable_workspace_id};
+ use std::fs;
+
+ #[test]
+ fn normalize_local_workspace_path_expands_home_prefix() {
+ let home = crate::paths::home_dir().expect("home dir");
+ let expected = home.join("OpenWork").join("openwork-state-test-expand");
+ let actual = normalize_local_workspace_path("~/OpenWork/openwork-state-test-expand");
+ assert_eq!(actual, expected.to_string_lossy());
+ }
+
+ #[test]
+ fn normalize_local_workspace_path_keeps_canonical_id_stable() {
+ let temp = std::env::temp_dir().join(format!(
+ "openwork-workspace-state-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("clock")
+ .as_nanos()
+ ));
+ let nested = temp.join("starter");
+ fs::create_dir_all(&nested).expect("create temp workspace");
+
+ let raw = format!("{}/../starter", nested.display());
+ let normalized = normalize_local_workspace_path(&raw);
+
+ let canonical = fs::canonicalize(&nested).expect("canonical starter workspace");
+ assert_eq!(normalized, canonical.to_string_lossy());
+ assert_eq!(
+ stable_workspace_id(&normalized),
+ stable_workspace_id(&canonical.to_string_lossy())
+ );
+
+ let _ = fs::remove_dir_all(&temp);
+ }
+}
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index 55e3e1bd..6423e46b 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenWork",
- "version": "0.11.182",
+ "version": "0.11.186",
"identifier": "com.differentai.openwork",
"build": {
"beforeDevCommand": "node ./scripts/tauri-before-dev.mjs",
diff --git a/apps/opencode-router/package.json b/apps/opencode-router/package.json
index 8432e7a2..0b839154 100644
--- a/apps/opencode-router/package.json
+++ b/apps/opencode-router/package.json
@@ -1,6 +1,6 @@
{
"name": "opencode-router",
- "version": "0.11.182",
+ "version": "0.11.186",
"description": "opencode-router: Slack + Telegram bridge + directory routing for a running opencode server",
"private": false,
"type": "module",
diff --git a/apps/orchestrator/package.json b/apps/orchestrator/package.json
index e7fdbd24..f72248cc 100644
--- a/apps/orchestrator/package.json
+++ b/apps/orchestrator/package.json
@@ -1,6 +1,6 @@
{
"name": "openwork-orchestrator",
- "version": "0.11.182",
+ "version": "0.11.186",
"description": "OpenWork host orchestrator for opencode + OpenWork server + opencode-router",
"private": true,
"type": "module",
@@ -47,8 +47,8 @@
"@opencode-ai/sdk": "^1.1.31",
"@opentui/core": "0.1.77",
"@opentui/solid": "0.1.77",
- "opencode-router": "0.11.182",
- "openwork-server": "0.11.182",
+ "opencode-router": "0.11.186",
+ "openwork-server": "0.11.186",
"solid-js": "1.9.9"
},
"devDependencies": {
diff --git a/apps/orchestrator/src/cli.ts b/apps/orchestrator/src/cli.ts
index 3e1586cc..c8e46fb5 100644
--- a/apps/orchestrator/src/cli.ts
+++ b/apps/orchestrator/src/cli.ts
@@ -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;
@@ -438,6 +440,16 @@ function readBool(
return fallback;
}
+function readOptionalBool(value: unknown): boolean | undefined {
+ if (typeof value === "boolean") return value;
+ if (typeof value === "string") {
+ const normalized = value.trim().toLowerCase();
+ if (["true", "1", "yes", "on"].includes(normalized)) return true;
+ if (["false", "0", "no", "off"].includes(normalized)) return false;
+ }
+ return undefined;
+}
+
function readNumber(
flags: Map,
key: string,
@@ -1203,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(result: FieldsResult): T {
if (result.data !== undefined) {
return result.data;
@@ -2618,6 +2753,148 @@ function resolveRouterDataDir(flags: Map): string {
return join(homedir(), ".openwork", "openwork-orchestrator");
}
+function resolveWorkspaceOpenworkConfigPath(workspaceRoot: string): string {
+ return join(workspaceRoot, ".opencode", "openwork.json");
+}
+
+function resolveOpencodeRouterConfigPath(): string {
+ const override = process.env.OPENCODE_ROUTER_CONFIG_PATH?.trim();
+ if (override) return resolve(override.replace(/^~\//, `${homedir()}/`));
+ const dataDir =
+ process.env.OPENCODE_ROUTER_DATA_DIR?.trim() ||
+ join(homedir(), ".openwork", "opencode-router");
+ const expanded = dataDir.replace(/^~\//, `${homedir()}/`);
+ return join(resolve(expanded), "opencode-router.json");
+}
+
+function asRecord(value: unknown): Record {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+function readMessagingEnabledFromOpenworkConfig(
+ openworkConfig: Record,
+): boolean | undefined {
+ const messaging = asRecord(openworkConfig.messaging);
+ return readOptionalBool(messaging.enabled);
+}
+
+function hasConfiguredMessagingServices(routerConfig: Record): boolean {
+ const channels = asRecord(routerConfig.channels);
+
+ const telegram = asRecord(channels.telegram);
+ const legacyTelegramToken =
+ typeof telegram.token === "string" ? telegram.token.trim() : "";
+ if (legacyTelegramToken) return true;
+ const telegramBots = Array.isArray(telegram.bots) ? telegram.bots : [];
+ if (
+ telegramBots.some((bot) => {
+ const record = asRecord(bot);
+ return (
+ typeof record.token === "string" && record.token.trim().length > 0
+ );
+ })
+ ) {
+ return true;
+ }
+
+ const slack = asRecord(channels.slack);
+ const legacySlackBotToken =
+ typeof slack.botToken === "string" ? slack.botToken.trim() : "";
+ const legacySlackAppToken =
+ typeof slack.appToken === "string" ? slack.appToken.trim() : "";
+ if (legacySlackBotToken && legacySlackAppToken) return true;
+ const slackApps = Array.isArray(slack.apps) ? slack.apps : [];
+ if (
+ slackApps.some((app) => {
+ const record = asRecord(app);
+ const botToken =
+ typeof record.botToken === "string" ? record.botToken.trim() : "";
+ const appToken =
+ typeof record.appToken === "string" ? record.appToken.trim() : "";
+ return Boolean(botToken && appToken);
+ })
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+async function resolveOpencodeRouterEnabled(
+ flags: Map,
+ workspaceRoot: string,
+ logger: Logger,
+): Promise<{
+ enabled: boolean;
+ source: "flag" | "env" | "workspace-config" | "inferred";
+}> {
+ const flagValue = flags.get("opencode-router");
+ const parsedFlag = readOptionalBool(flagValue);
+ if (parsedFlag !== undefined) {
+ return { enabled: parsedFlag, source: "flag" };
+ }
+
+ const envValue = readOptionalBool(
+ process.env.OPENWORK_OPENCODE_ROUTER,
+ );
+ if (envValue !== undefined) {
+ return { enabled: envValue, source: "env" };
+ }
+
+ const openworkConfigPath = resolveWorkspaceOpenworkConfigPath(workspaceRoot);
+ let openworkConfig: Record = {};
+ try {
+ const raw = await readFile(openworkConfigPath, "utf8");
+ openworkConfig = asRecord(JSON.parse(raw));
+ } catch {
+ openworkConfig = {};
+ }
+
+ const configured = readMessagingEnabledFromOpenworkConfig(openworkConfig);
+ if (configured !== undefined) {
+ return { enabled: configured, source: "workspace-config" };
+ }
+
+ let inferredEnabled = false;
+ const routerConfigPath = resolveOpencodeRouterConfigPath();
+ try {
+ const raw = await readFile(routerConfigPath, "utf8");
+ inferredEnabled = hasConfiguredMessagingServices(asRecord(JSON.parse(raw)));
+ } catch {
+ inferredEnabled = false;
+ }
+
+ const nextOpenworkConfig: Record = {
+ ...openworkConfig,
+ messaging: {
+ ...asRecord(openworkConfig.messaging),
+ enabled: inferredEnabled,
+ },
+ };
+
+ try {
+ await mkdir(dirname(openworkConfigPath), { recursive: true });
+ await writeFile(
+ openworkConfigPath,
+ `${JSON.stringify(nextOpenworkConfig, null, 2)}\n`,
+ "utf8",
+ );
+ } catch (error) {
+ logger.warn(
+ "Failed to persist messaging enabled default",
+ {
+ path: openworkConfigPath,
+ error: error instanceof Error ? error.message : String(error),
+ },
+ "openwork-orchestrator",
+ );
+ }
+
+ return { enabled: inferredEnabled, source: "inferred" };
+}
+
function resolveInternalDevMode(flags: Map): boolean {
return readBool(flags, "internal-dev-mode", false, "OPENWORK_DEV_MODE");
}
@@ -3303,18 +3580,18 @@ function printHelp(): void {
" --daemon-host Host for orchestrator router daemon (default: 127.0.0.1)",
" --daemon-port Port for orchestrator router daemon (default: random)",
" --opencode-bin Path to opencode binary (requires --allow-external)",
- " --opencode-host Bind host for opencode serve (default: 0.0.0.0)",
+ " --opencode-host Bind host for opencode serve (loopback only, default: 127.0.0.1)",
" --opencode-port Port for opencode serve (default: random)",
" --opencode-workdir 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 Debounce window for hot reload triggers (default: 700)",
" --opencode-hot-reload-cooldown-ms Minimum interval between hot reloads (default: 1500)",
- " --opencode-username OpenCode basic auth username",
- " --opencode-password OpenCode basic auth password",
- " --openwork-host Bind host for openwork-server (default: 0.0.0.0)",
+ " --opencode-username Internal-only override for managed OpenCode auth username",
+ " --opencode-password Internal-only override for managed OpenCode auth password",
+ " --openwork-host Bind host for openwork-server (default: 127.0.0.1)",
" --openwork-port Port for openwork-server (default: 8787)",
+ " --remote-access Expose OpenWork on 0.0.0.0 for remote sharing",
" --openwork-token Client token for openwork-server",
" --openwork-host-token Host token for approvals",
" --workspace-id Workspace id for file session commands",
@@ -3339,6 +3616,7 @@ function printHelp(): void {
" --openwork-server-bin Path to openwork-server binary (requires --allow-external)",
" --opencode-router-bin Path to opencodeRouter binary (requires --allow-external)",
" --opencode-router-health-port Health server port for opencodeRouter (default: random)",
+ " --opencode-router Enable opencodeRouter sidecar (default from workspace messaging config)",
" --no-opencode-router Disable opencodeRouter sidecar",
" --opencode-router-required Exit if opencodeRouter stops",
" --allow-external Allow external sidecar binaries (dev only, required for custom bins)",
@@ -4074,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",
@@ -4119,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}`,
);
}
@@ -4236,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",
@@ -4281,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}`,
);
}
@@ -5012,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) {
@@ -5088,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 =
@@ -5104,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(
@@ -5145,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);
@@ -5406,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,
@@ -5560,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,
@@ -6489,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
@@ -6518,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",
@@ -6677,13 +6926,21 @@ async function runStart(args: ParsedArgs) {
}
}
}
- const opencodeRouterEnabled = readBool(args.flags, "opencode-router", true);
+ const opencodeRouterMode = await resolveOpencodeRouterEnabled(
+ args.flags,
+ resolvedWorkspace,
+ logger,
+ );
+ const opencodeRouterEnabled = opencodeRouterMode.enabled;
const opencodeRouterRequired = readBool(
args.flags,
"opencode-router-required",
false,
"OPENWORK_OPENCODE_ROUTER_REQUIRED",
);
+ logVerbose(
+ `opencodeRouter enabled: ${opencodeRouterEnabled ? "true" : "false"} (${opencodeRouterMode.source})`,
+ );
let openworkServerBinary = await resolveOpenworkServerBin({
explicit: explicitOpenworkServerBin,
manifest,
@@ -6724,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 =
@@ -6734,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"
@@ -6744,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}`;
@@ -6881,7 +7139,7 @@ async function runStart(args: ParsedArgs) {
headers:
opencodeUsername && opencodePassword
? {
- Authorization: `Basic ${encodeBasicAuth(opencodeUsername, opencodePassword)}`,
+ Authorization: `Basic ${encodeBasicAuth(opencodeCredentials.username, opencodeCredentials.password)}`,
}
: undefined,
}),
diff --git a/apps/server/package.json b/apps/server/package.json
index 3f7a5cb6..f5d527d0 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -1,6 +1,6 @@
{
"name": "openwork-server",
- "version": "0.11.182",
+ "version": "0.11.186",
"description": "Filesystem-backed API for OpenWork remote clients",
"type": "module",
"bin": {
diff --git a/apps/share/README.md b/apps/share/README.md
index 16e60dc3..9e7a7199 100644
--- a/apps/share/README.md
+++ b/apps/share/README.md
@@ -66,9 +66,13 @@ The packager rejects files that appear to contain secrets in shareable config.
- Used to construct the returned share URL.
- `MAX_BYTES`
- - Default: `5242880` (5MB)
+ - Default: `262144` (256KB)
- Hard upload limit.
+- `OPENWORK_PUBLISHER_ALLOWED_ORIGINS`
+ - Optional comma-separated browser origins allowed to publish bundles.
+ - Defaults include the share origin, the hosted OpenWork app origin, and common local dev origins.
+
- `PUBLIC_OPENWORK_APP_URL`
- Default: `https://app.openwork.software`
- Target app URL for the Open in app action on bundle pages.
@@ -99,6 +103,7 @@ Recommended project settings:
- Build command: `next build`
- Output directory: `.next`
- Install command: `pnpm install --frozen-lockfile`
+- Enable Vercel BotID for the project and keep the bundle routes protected in `app/layout.tsx`.
## Tests
diff --git a/apps/share/app/api/v1/bundles/route.ts b/apps/share/app/api/v1/bundles/route.ts
index b80edc2f..5f63bf31 100644
--- a/apps/share/app/api/v1/bundles/route.ts
+++ b/apps/share/app/api/v1/bundles/route.ts
@@ -1,4 +1,5 @@
import { storeBundleJson } from "../../../../server/_lib/blob-store.ts";
+import { buildCorsHeaders, rateLimitPublishRequest, validateTrustedOrigin, verifyShareBotProtection } from "../../../../server/_lib/publish-security.ts";
import { buildBundleUrls, getEnv, validateBundlePayload } from "../../../../server/_lib/share-utils.ts";
import { buildRequestLike } from "../../../../server/_lib/request-like.ts";
@@ -12,50 +13,64 @@ function formatPublishError(error: unknown): string {
return message;
}
-function buildCorsHeaders(): Record {
- return {
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
- "Access-Control-Allow-Headers": "Content-Type,Accept,X-OpenWork-Bundle-Type,X-OpenWork-Schema-Version,X-OpenWork-Name"
- };
-}
-
-function jsonResponse(body: unknown, status = 200): Response {
+function jsonResponse(body: unknown, request: Request, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
- ...buildCorsHeaders(),
+ ...buildCorsHeaders(request),
"Content-Type": "application/json"
}
});
}
-export function OPTIONS() {
+export function OPTIONS(request: Request) {
return new Response(null, {
status: 204,
- headers: buildCorsHeaders()
+ headers: buildCorsHeaders(request)
});
}
export async function POST(request: Request) {
- const maxBytes = Number.parseInt(getEnv("MAX_BYTES", "5242880"), 10);
+ const originCheck = validateTrustedOrigin(request);
+ if (!originCheck.ok) {
+ return jsonResponse({ message: originCheck.message }, request, originCheck.status);
+ }
+
+ const rateLimit = rateLimitPublishRequest(request);
+ if (!rateLimit.ok) {
+ return new Response(JSON.stringify({ message: "Publishing is temporarily rate limited." }), {
+ status: 429,
+ headers: {
+ ...buildCorsHeaders(request),
+ "Content-Type": "application/json",
+ "X-Retry-After": String(rateLimit.retryAfterSeconds),
+ },
+ });
+ }
+
+ const botProtection = await verifyShareBotProtection(request);
+ if (!botProtection.ok) {
+ return jsonResponse({ message: botProtection.message }, request, botProtection.status);
+ }
+
+ const maxBytes = Number.parseInt(getEnv("MAX_BYTES", "262144"), 10);
const contentType = String(request.headers.get("content-type") ?? "").toLowerCase();
if (!contentType.includes("application/json")) {
- return jsonResponse({ message: "Expected application/json" }, 415);
+ return jsonResponse({ message: "Expected application/json" }, request, 415);
}
const rawJson = await request.text();
if (!rawJson) {
- return jsonResponse({ message: "Body is required" }, 400);
+ return jsonResponse({ message: "Body is required" }, request, 400);
}
if (Buffer.byteLength(rawJson, "utf8") > maxBytes) {
- return jsonResponse({ message: "Bundle exceeds upload limit", maxBytes }, 413);
+ return jsonResponse({ message: "Bundle exceeds upload limit", maxBytes }, request, 413);
}
const validation = validateBundlePayload(rawJson);
if (!validation.ok) {
- return jsonResponse({ message: validation.message }, 422);
+ return jsonResponse({ message: validation.message }, request, 422);
}
try {
@@ -67,8 +82,8 @@ export async function POST(request: Request) {
id
);
- return jsonResponse({ url: urls.shareUrl });
+ return jsonResponse({ url: urls.shareUrl }, request);
} catch (error) {
- return jsonResponse({ message: formatPublishError(error) }, 500);
+ return jsonResponse({ message: formatPublishError(error) }, request, 500);
}
}
diff --git a/apps/share/app/api/v1/package/route.ts b/apps/share/app/api/v1/package/route.ts
index 7eaa05ab..13500f3a 100644
--- a/apps/share/app/api/v1/package/route.ts
+++ b/apps/share/app/api/v1/package/route.ts
@@ -1,5 +1,6 @@
import { storeBundleJson } from "../../../../server/_lib/blob-store.ts";
import { packageOpenworkFiles } from "../../../../server/_lib/package-openwork-files.ts";
+import { buildCorsHeaders, rateLimitPublishRequest, validateTrustedOrigin, verifyShareBotProtection } from "../../../../server/_lib/publish-security.ts";
import { buildBundleUrls, getEnv } from "../../../../server/_lib/share-utils.ts";
import { buildRequestLike } from "../../../../server/_lib/request-like.ts";
@@ -13,58 +14,72 @@ function formatPublishError(error: unknown): string {
return message;
}
-function buildCorsHeaders(): Record {
- return {
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
- "Access-Control-Allow-Headers": "Content-Type,Accept,X-OpenWork-Bundle-Type,X-OpenWork-Schema-Version,X-OpenWork-Name"
- };
-}
-
-function jsonResponse(body: unknown, status = 200): Response {
+function jsonResponse(body: unknown, request: Request, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
- ...buildCorsHeaders(),
+ ...buildCorsHeaders(request),
"Content-Type": "application/json"
}
});
}
-export function OPTIONS() {
+export function OPTIONS(request: Request) {
return new Response(null, {
status: 204,
- headers: buildCorsHeaders()
+ headers: buildCorsHeaders(request)
});
}
export async function POST(request: Request) {
- const maxBytes = Number.parseInt(getEnv("MAX_BYTES", "5242880"), 10);
+ const originCheck = validateTrustedOrigin(request);
+ if (!originCheck.ok) {
+ return jsonResponse({ message: originCheck.message }, request, originCheck.status);
+ }
+
+ const rateLimit = rateLimitPublishRequest(request);
+ if (!rateLimit.ok) {
+ return new Response(JSON.stringify({ message: "Publishing is temporarily rate limited." }), {
+ status: 429,
+ headers: {
+ ...buildCorsHeaders(request),
+ "Content-Type": "application/json",
+ "X-Retry-After": String(rateLimit.retryAfterSeconds),
+ },
+ });
+ }
+
+ const botProtection = await verifyShareBotProtection(request);
+ if (!botProtection.ok) {
+ return jsonResponse({ message: botProtection.message }, request, botProtection.status);
+ }
+
+ const maxBytes = Number.parseInt(getEnv("MAX_BYTES", "262144"), 10);
const contentType = String(request.headers.get("content-type") ?? "").toLowerCase();
if (!contentType.includes("application/json")) {
- return jsonResponse({ message: "Expected application/json" }, 415);
+ return jsonResponse({ message: "Expected application/json" }, request, 415);
}
const raw = await request.text();
if (!raw) {
- return jsonResponse({ message: "Body is required" }, 400);
+ return jsonResponse({ message: "Body is required" }, request, 400);
}
if (Buffer.byteLength(raw, "utf8") > maxBytes) {
- return jsonResponse({ message: "Package request exceeds upload limit", maxBytes }, 413);
+ return jsonResponse({ message: "Package request exceeds upload limit", maxBytes }, request, 413);
}
let body: { preview?: boolean; [key: string]: unknown };
try {
body = JSON.parse(raw);
} catch {
- return jsonResponse({ message: "Invalid JSON" }, 422);
+ return jsonResponse({ message: "Invalid JSON" }, request, 422);
}
try {
const packaged = packageOpenworkFiles(body);
if (body?.preview) {
- return jsonResponse(packaged);
+ return jsonResponse(packaged, request);
}
const { id } = await storeBundleJson(JSON.stringify(packaged.bundle));
@@ -79,8 +94,8 @@ export async function POST(request: Request) {
...packaged,
url: urls.shareUrl,
id
- });
+ }, request);
} catch (error) {
- return jsonResponse({ message: formatPublishError(error) }, 422);
+ return jsonResponse({ message: formatPublishError(error) }, request, 422);
}
}
diff --git a/apps/share/app/layout.tsx b/apps/share/app/layout.tsx
index ee8f72ed..5aad97a5 100644
--- a/apps/share/app/layout.tsx
+++ b/apps/share/app/layout.tsx
@@ -3,6 +3,7 @@ import "../styles/globals.css";
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import Script from "next/script";
+import { BotIdClient } from "botid/client";
import { DEFAULT_PUBLIC_BASE_URL } from "../server/_lib/share-utils.ts";
@@ -56,10 +57,16 @@ posthog.init(${JSON.stringify(posthogKey)}, {
});`
: "";
+const protectedRoutes = [
+ { path: "/v1/package", method: "POST" as const },
+ { path: "/v1/bundles", method: "POST" as const },
+];
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
+
{posthogBootstrap ? (
) : null}
diff --git a/apps/share/next.config.mjs b/apps/share/next.config.mjs
new file mode 100644
index 00000000..287bc64a
--- /dev/null
+++ b/apps/share/next.config.mjs
@@ -0,0 +1,8 @@
+import { withBotId } from "botid/next/config";
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+};
+
+export default withBotId(nextConfig);
diff --git a/apps/share/package.json b/apps/share/package.json
index c2cd0adf..ddd2930c 100644
--- a/apps/share/package.json
+++ b/apps/share/package.json
@@ -13,6 +13,7 @@
"dependencies": {
"@paper-design/shaders-react": "0.0.71",
"@vercel/blob": "^0.27.0",
+ "botid": "^1.5.11",
"jsonc-parser": "^3.3.1",
"next": "16.1.6",
"react": "19.2.4",
diff --git a/apps/share/server/_lib/publish-security.ts b/apps/share/server/_lib/publish-security.ts
new file mode 100644
index 00000000..d5f50972
--- /dev/null
+++ b/apps/share/server/_lib/publish-security.ts
@@ -0,0 +1,121 @@
+import { checkBotId } from "botid/server";
+
+type FixedWindowEntry = {
+ count: number;
+ resetAt: number;
+};
+
+const defaultAllowedOrigins = [
+ "https://app.openwork.software",
+ "https://openwork.software",
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+ "http://localhost:3000",
+ "http://127.0.0.1:3000",
+ "http://localhost:3006",
+ "http://127.0.0.1:3006",
+];
+
+const store = globalThis as typeof globalThis & {
+ __openworkShareRateLimitStore?: Map;
+};
+
+const rateLimitStore = store.__openworkShareRateLimitStore ?? new Map();
+store.__openworkShareRateLimitStore = rateLimitStore;
+
+function now() {
+ return Date.now();
+}
+
+function readClientIp(request: Request) {
+ const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
+ const realIp = request.headers.get("x-real-ip")?.trim();
+ return forwarded || realIp || "unknown";
+}
+
+function getRequestOrigin(request: Request) {
+ try {
+ return new URL(request.url).origin;
+ } catch {
+ return "";
+ }
+}
+
+export function getAllowedOrigins(request: Request) {
+ const configured = String(process.env.OPENWORK_PUBLISHER_ALLOWED_ORIGINS ?? "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ return new Set([getRequestOrigin(request), ...defaultAllowedOrigins, ...configured].filter(Boolean));
+}
+
+export function buildCorsHeaders(request: Request) {
+ const origin = request.headers.get("origin")?.trim() ?? "";
+ const headers: Record = {
+ "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type,Accept,X-OpenWork-Bundle-Type,X-OpenWork-Schema-Version,X-OpenWork-Name",
+ };
+ if (origin && getAllowedOrigins(request).has(origin)) {
+ headers["Access-Control-Allow-Origin"] = origin;
+ headers["Vary"] = "Origin";
+ }
+ return headers;
+}
+
+export function validateTrustedOrigin(request: Request) {
+ const origin = request.headers.get("origin")?.trim() ?? "";
+ if (!origin) {
+ return { ok: false as const, status: 403, message: "A trusted browser origin is required." };
+ }
+ if (!getAllowedOrigins(request).has(origin)) {
+ return { ok: false as const, status: 403, message: "Origin is not allowed to publish bundles." };
+ }
+ return { ok: true as const, origin };
+}
+
+export function applyFixedWindowRateLimit(input: {
+ key: string;
+ windowMs: number;
+ max: number;
+}) {
+ const currentTime = now();
+ const current = rateLimitStore.get(input.key);
+ if (!current || current.resetAt <= currentTime) {
+ rateLimitStore.set(input.key, { count: 1, resetAt: currentTime + input.windowMs });
+ return { ok: true as const, retryAfterSeconds: 0 };
+ }
+
+ if (current.count >= input.max) {
+ return {
+ ok: false as const,
+ retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - currentTime) / 1000)),
+ };
+ }
+
+ current.count += 1;
+ rateLimitStore.set(input.key, current);
+ return { ok: true as const, retryAfterSeconds: 0 };
+}
+
+export function rateLimitPublishRequest(request: Request) {
+ return applyFixedWindowRateLimit({
+ key: `publish:${readClientIp(request)}`,
+ windowMs: 60_000,
+ max: 20,
+ });
+}
+
+export async function verifyShareBotProtection(request: Request) {
+ const requestOrigin = getRequestOrigin(request);
+ const origin = request.headers.get("origin")?.trim() ?? "";
+ if (!origin || origin !== requestOrigin) {
+ return { ok: true as const };
+ }
+
+ const result = await checkBotId();
+ if (result.isBot) {
+ return { ok: false as const, status: 403, message: "Bot traffic is not allowed for bundle publishing." };
+ }
+
+ return { ok: true as const };
+}
diff --git a/docs/plans/2026-03-11-den-page-refresh.md b/docs/plans/2026-03-11-den-page-refresh.md
deleted file mode 100644
index 6108addd..00000000
--- a/docs/plans/2026-03-11-den-page-refresh.md
+++ /dev/null
@@ -1,395 +0,0 @@
-# Den Page Refresh Implementation Plan
-
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Rewrite the public `/den` marketing page so a founder or team lead can understand Den in one fast scroll and take the "deploy your first worker" action.
-
-**Architecture:** Implement the refresh in the landing app that actually serves `openwork.software/den`, not in the Den control-plane service. Keep the existing nav/footer/layout primitives, replace the page body with the new hero, use-case cards, details, and pricing sections, and add a reusable carbon-window activity panel plus a small set of landing-specific style tokens and motion helpers.
-
-**Tech Stack:** Next.js app router, React, Tailwind utility classes, landing global CSS in `packages/landing/app/globals.css`, Framer Motion for staggered reveals.
-
----
-
-## Recommended Target
-
-The brief says the page "should be under `services/den`", but the live route is currently rendered from:
-
-- `packages/landing/app/den/page.tsx`
-- `packages/landing/components/landing-den.tsx`
-
-`services/den` currently serves a plain control-plane demo at `/`, not the public marketing site. Updating only `services/den` would not update `openwork.software/den` unless routing/deployment architecture also changes.
-
-## Approach Options
-
-### Option A: Update `packages/landing` only (recommended)
-
-```text
-Browser -> openwork.software
- -> packages/landing app
- -> /den page
-```
-
-Why:
-- Matches the current production route.
-- Reuses existing nav, footer, background, chips, and page shell.
-- Smallest diff with the highest confidence.
-
-### Option B: Move `/den` ownership to `services/den`
-
-```text
-Browser -> openwork.software/den
- -> services/den
- -> marketing page + control-plane demo
-```
-
-Why not:
-- Requires routing/deployment changes, not just page work.
-- Mixes marketing and control-plane demo concerns in one service.
-- Higher risk for no product gain.
-
-### Option C: Build shared Den page primitives used by both apps
-
-```text
-shared Den UI
- -> packages/landing /den
- -> services/den demo shell
-```
-
-Why not:
-- Premature abstraction for a single landing page refresh.
-- Adds coordination cost between two runtimes.
-
-## Recommendation
-
-Use Option A. If the team later wants the Den service to host the same marketing content, extract shared pieces after the copy and layout settle.
-
-## Affected Files
-
-**Primary edits**
-- Modify: `packages/landing/components/landing-den.tsx`
-- Modify: `packages/landing/app/den/page.tsx`
-- Modify: `packages/landing/components/site-footer.tsx`
-- Modify: `packages/landing/app/globals.css`
-
-**Reference files**
-- Check: `DESIGN-LANGUAGE.md`
-- Check: `packages/landing/components/site-nav.tsx`
-- Check: `packages/landing/components/landing-home.tsx`
-- Check: `packages/landing/README.md`
-
-**Verification artifacts**
-- Create: `packages/landing/pr/screenshots/den-page-refresh/` (or the repo’s current PR artifact location for landing screenshots)
-
----
-
-### Task 1: Lock the real target and content model
-
-**Files:**
-- Check: `packages/landing/app/den/page.tsx`
-- Check: `packages/landing/components/landing-den.tsx`
-- Check: `packages/landing/components/site-footer.tsx`
-- Check: `DESIGN-LANGUAGE.md`
-
-**Step 1: Confirm routing assumption**
-
-Run:
-
-```bash
-rg -n "LandingDen|app/den/page" packages/landing -S
-```
-
-Expected: `/den` resolves through `packages/landing`.
-
-**Step 2: Map the brief into concrete page sections**
-
-Create a small in-file content model in `packages/landing/components/landing-den.tsx` for:
-- hero trust chips
-- activity timeline entries
-- use-case cards
-- details grid items
-
-Keep copy literal and short. Do not introduce CMS-style abstractions.
-
-**Step 3: Commit**
-
-```bash
-git add packages/landing/components/landing-den.tsx
-git commit -m "refactor(landing): prepare den page content structure"
-```
-
----
-
-### Task 2: Rebuild the hero around the new value prop
-
-**Files:**
-- Modify: `packages/landing/components/landing-den.tsx`
-- Check: `packages/landing/components/site-nav.tsx`
-
-**Step 1: Replace the current hero copy**
-
-Update the hero to:
-- keep eyebrow `OpenWork hosted`
-- keep heading `Swarms`
-- change subheading to `Always-on AI workers for your team.`
-- change body to the new brief copy
-- change CTA label to `Deploy your first worker`
-- change pricing line to `$50/mo per worker · Cancel anytime`
-- remove the expired early-adopter line
-
-**Step 2: Add trust chips below the CTA**
-
-Render four chips:
-- `YC backed`
-- `11.5K stars`
-- `Open source`
-- `50+ LLMs`
-
-Use the existing chip/shell styling instead of inventing a new component.
-
-**Step 3: Convert the hero layout to a two-column grid**
-
-Use a layout equivalent to:
-
-```text
-| left: copy + CTA + trust chips | right: carbon worker activity panel |
-```
-
-On mobile, stack the carbon panel below the copy.
-
-**Step 4: Commit**
-
-```bash
-git add packages/landing/components/landing-den.tsx
-git commit -m "feat(landing): rebuild den hero"
-```
-
----
-
-### Task 3: Add the carbon-window worker activity panel
-
-**Files:**
-- Modify: `packages/landing/components/landing-den.tsx`
-- Modify: `packages/landing/app/globals.css`
-
-**Step 1: Build a small presentational carbon-window block**
-
-Inside `landing-den.tsx`, add a lightweight presentational component or local JSX block for:
-- dark panel shell
-- titlebar with mac dots
-- worker name `ops-worker-01`
-- `RUNNING` status row
-- 5 activity entries from the brief
-
-Do not pull in unrelated shared app window code. The landing page only needs a visual panel.
-
-**Step 2: Add semantic styling hooks**
-
-Add CSS classes for:
-- carbon shell background `#151718`
-- carbon titlebar `#1d1f21`
-- mono timestamps
-- small source pills
-- status dots for success, warning, critical
-- pulsing running indicator
-
-**Step 3: Add staggered reveal motion**
-
-Use Framer Motion or CSS animation for entry fade-up with small stagger.
-Respect `prefers-reduced-motion` by disabling pulse/stagger in `globals.css`.
-
-**Step 4: Commit**
-
-```bash
-git add packages/landing/components/landing-den.tsx packages/landing/app/globals.css
-git commit -m "feat(landing): add den worker activity panel"
-```
-
----
-
-### Task 4: Replace feature cards with use-case cards
-
-**Files:**
-- Modify: `packages/landing/components/landing-den.tsx`
-- Modify: `packages/landing/app/globals.css`
-
-**Step 1: Remove the current infrastructure-first feature card section**
-
-Delete:
-- `Hosted sandboxed workers`
-- `Desktop, Slack, and Telegram access`
-- `Skills, agents, and MCP included`
-
-**Step 2: Add three use-case cards**
-
-Render cards for:
-- Ops
-- Code
-- Content
-
-Each card should contain:
-- gradient category dot
-- uppercase label
-- short title
-- one-sentence description
-- mono detail line
-
-**Step 3: Reuse the existing frosted-card treatment**
-
-Keep:
-- rounded cards
-- hover lift
-- frosted shell feel
-
-Only add minimal new CSS if utility classes are not enough.
-
-**Step 4: Commit**
-
-```bash
-git add packages/landing/components/landing-den.tsx packages/landing/app/globals.css
-git commit -m "feat(landing): turn den features into use cases"
-```
-
----
-
-### Task 5: Replace the OpenCode block with details + pricing
-
-**Files:**
-- Modify: `packages/landing/components/landing-den.tsx`
-- Modify: `packages/landing/components/site-footer.tsx`
-
-**Step 1: Replace the current lower section**
-
-Add a two-column details section:
-
-Left:
-- `How it works`
-- short paragraph from the brief
-
-Right:
-- 2x3 grid of short feature labels with blue arrow markers
-
-**Step 2: Add a dedicated pricing section**
-
-Centered section with:
-- bold `$50/month per worker.`
-- the human-time comparison sentence
-- CTA `Deploy your first worker`
-- subtext `No credit card to start`
-
-**Step 3: Update the footer copy**
-
-Keep the structure, but add `Backed by Y Combinator` to the copyright line.
-Do not move footer nav around unless needed for spacing.
-
-**Step 4: Commit**
-
-```bash
-git add packages/landing/components/landing-den.tsx packages/landing/components/site-footer.tsx
-git commit -m "feat(landing): add den details and pricing sections"
-```
-
----
-
-### Task 6: Tighten metadata and CTA wiring
-
-**Files:**
-- Modify: `packages/landing/app/den/page.tsx`
-- Check: `packages/landing/components/landing-den.tsx`
-
-**Step 1: Update metadata description**
-
-Replace the current infrastructure description with copy aligned to the new positioning:
-
-```ts
-"Always-on AI workers that handle repetitive work for your team and report back in Slack, Telegram, or the desktop app."
-```
-
-**Step 2: Confirm CTA destination**
-
-Keep `getStartedHref="https://app.openwork.software"` unless product wants a different checkout or onboarding URL. The brief changes the label, not necessarily the target.
-
-**Step 3: Commit**
-
-```bash
-git add packages/landing/app/den/page.tsx
-git commit -m "chore(landing): update den metadata"
-```
-
----
-
-### Task 7: Verify the real page and capture artifacts
-
-**Files:**
-- Check: `packages/landing/README.md`
-- Create: `packages/landing/pr/screenshots/den-page-refresh/`
-
-**Step 1: Run the landing app locally**
-
-Run:
-
-```bash
-pnpm --filter @different-ai/openwork-landing dev
-```
-
-Expected: local Next.js landing app starts successfully.
-
-**Step 2: Validate the `/den` route**
-
-Open the local landing URL in Chrome MCP and check:
-- desktop layout
-- mobile layout
-- hero copy readability in one viewport
-- carbon panel animation and reduced-motion sanity
-- trust chips and CTA placement
-- details and pricing sections fit the brief
-- footer line includes YC wording
-
-**Step 3: Run a production build**
-
-Run:
-
-```bash
-pnpm --filter @different-ai/openwork-landing build
-```
-
-Expected: successful Next.js production build.
-
-**Step 4: Save screenshots**
-
-Capture at minimum:
-- desktop hero
-- desktop full page
-- mobile hero
-- mobile lower sections
-
-**Step 5: Optional service cleanup decision**
-
-Decide whether to leave `services/den/public/index.html` as the control-plane demo or move that demo to a non-root path later. Do not combine that cleanup with this marketing refresh unless there is explicit product direction.
-
-**Step 6: Commit**
-
-```bash
-git add packages/landing/pr/screenshots/den-page-refresh
-git commit -m "docs(pr): add den page verification artifacts"
-```
-
----
-
-## Risks and Guardrails
-
-- Do not implement this in `services/den` unless the routing/deployment target changes first.
-- Do not over-componentize the page. One local component for the activity panel is enough.
-- Do not preserve the old "Powered by OpenCode" section on this page. The brief is explicit that it weakens the pitch.
-- Keep the page tight. Each major section should fit roughly in one viewport.
-- Keep motion subtle. The panel should feel alive, not noisy.
-
-## Verification Checklist
-
-- `/den` reads like a product pitch, not infra documentation.
-- The hero can be understood in under 10 seconds.
-- The CTA is specific.
-- Use cases are concrete and map to real team work.
-- Details and pricing are scannable.
-- Desktop and mobile both hold together.
-- `pnpm --filter @different-ai/openwork-landing build` passes.
-
diff --git a/docs/plans/2026-03-13-den-value-section-carbon-design.md b/docs/plans/2026-03-13-den-value-section-carbon-design.md
deleted file mode 100644
index 5dd61e30..00000000
--- a/docs/plans/2026-03-13-den-value-section-carbon-design.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# Den Value Section Carbon CTA Design
-
-**Goal:** Bring the Den pricing/value section into visual parity with the Den hero CTA system by using OpenWork's black/carbon palette, rounded pill CTAs, and cleaner responsive spacing.
-
-## Approved Scope
-
-- Keep the existing pricing section structure and copy.
-- Replace the bespoke CTA treatments in the value cards with the same rounded CTA language used in the hero.
-- Remove the blue featured-card accent language from the Den worker card and use black/carbon gray instead.
-- Add small responsive polish so the two cards read cleanly on desktop and stack cleanly on mobile.
-- Follow through on the same carbon treatment for the `What you get` icon badges and the `4. Review & Merge` step badge.
-
-## Layout
-
-Desktop:
-
-```text
-+----------------------------------------------------------------+
-| Pricing copy | [ Human repetitive work ] [ Den worker ]
-| | neutral pill button carbon pill CTA|
-| | gray accents carbon accents |
-+----------------------------------------------------------------+
-```
-
-Mobile:
-
-```text
-+------------------------+
-| Pricing copy |
-| [ Human card ] |
-| [ Den card ] |
-| full-width pill CTAs |
-| tighter spacing |
-+------------------------+
-```
-
-## Visual Direction
-
-- Primary CTA: reuse the shared `doc-button` style so the worker CTA matches the hero exactly.
-- Secondary CTA: use a neutral white/carbon rounded pill treatment that sits naturally next to the primary CTA.
-- Featured card: remove saturated blue fills, rings, and bullets. Replace them with carbon gradients, charcoal borders, and subtle shadow contrast.
-- Capability badges: use soft gray badge fills with carbon icon strokes so the carousel no longer introduces a separate blue system.
-- Workflow steps: keep step 1 blue as the intentional setup accent, but move step 4 to the same neutral carbon badge language.
-- Responsive polish: prevent narrow text and CTA wrapping issues with slightly tighter gaps, full-width buttons, and card content that fills the available height without depending on desktop spacing.
-
-## Verification
-
-- Run the landing page locally and verify `/den` in desktop and mobile widths.
-- Capture before/after screenshots of the value section for the PR.
-- Use real-page verification rather than adding a new test harness for this styling-only landing change.
diff --git a/docs/plans/2026-03-13-den-value-section-carbon-implementation.md b/docs/plans/2026-03-13-den-value-section-carbon-implementation.md
deleted file mode 100644
index 49be490f..00000000
--- a/docs/plans/2026-03-13-den-value-section-carbon-implementation.md
+++ /dev/null
@@ -1,73 +0,0 @@
-# Den Value Section Carbon CTA Implementation Plan
-
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Update the Den value section so its CTA and featured-card styling match the hero's black/carbon design language and stay readable on desktop and mobile.
-
-**Architecture:** Keep the existing section/component structure intact and make the change entirely inside the landing package. Reuse the shared `doc-button` class for the worker CTA, keep the human CTA as a neutral pill, and tune only the value-section layout classes needed for responsive balance.
-
-**Tech Stack:** Next.js, React, Tailwind utility classes, shared landing CSS in `packages/landing/app/globals.css`
-
----
-
-### Task 1: Document the approved UI direction
-
-**Files:**
-- Create: `docs/plans/2026-03-13-den-value-section-carbon-design.md`
-
-**Step 1:** Save the approved desktop/mobile layout and palette direction.
-
-**Step 2:** Include the verification expectation for screenshots on `/den`.
-
-**Step 3:** Commit with the implementation change once the section update is ready.
-
-### Task 2: Update the Den value section styling
-
-**Files:**
-- Modify: `packages/landing/components/den-value-section.tsx`
-
-**Step 1:** Reuse the shared `doc-button` class for the worker CTA.
-
-**Step 2:** Replace the blue featured-card borders, backgrounds, rings, bullets, and badge accents with black/carbon gray treatments.
-
-**Step 3:** Restyle the human CTA as a neutral rounded pill with border/shadow treatment compatible with the hero CTA system.
-
-**Step 4:** Tighten spacing and width behavior so the two cards stack well on mobile and keep their CTAs visually aligned on desktop.
-
-**Step 5:** Commit the UI change with a short imperative message.
-
-### Task 2b: Neutralize remaining blue icon accents on Den
-
-**Files:**
-- Modify: `packages/landing/components/den-capability-carousel.tsx`
-- Modify: `packages/landing/components/den-how-it-works.tsx`
-
-**Step 1:** Replace the blue icon treatment in `What you get` with gray badge fills and carbon icon color.
-
-**Step 2:** Keep step 1 blue in `How it works`, but move step 4 to a matching carbon/gray badge treatment.
-
-**Step 3:** Refresh the PR screenshots so reviewers can verify the follow-up polish pass.
-
-### Task 3: Verify the live page and collect artifacts
-
-**Files:**
-- Capture: `packages/landing/pr/2026-03-13-den-value-section-carbon-cta/*.png`
-
-**Step 1:** Start the landing page locally.
-
-**Step 2:** Open `/den` in a browser and inspect the value section at desktop and mobile widths.
-
-**Step 3:** Capture before/after screenshots for both widths.
-
-**Step 4:** Use the screenshots in the PR description alongside the problem statement and fix summary.
-
-### Task 4: Open the pull request
-
-**Files:**
-- No code changes
-
-**Step 1:** Push `code-factory/den-value-section-carbon-cta`.
-
-**Step 2:** Open a PR against `dev`.
-
-**Step 3:** Describe the bug, the styling changes, the responsive polish, and the screenshot evidence.
diff --git a/ee/apps/den-controller/.env.example b/ee/apps/den-controller/.env.example
index 53d1838f..783c5ea7 100644
--- a/ee/apps/den-controller/.env.example
+++ b/ee/apps/den-controller/.env.example
@@ -11,6 +11,7 @@ GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
LOOPS_API_KEY=
+LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL=
PORT=8788
WORKER_PROXY_PORT=8789
CORS_ORIGINS=http://localhost:3005,http://localhost:5173
diff --git a/ee/apps/den-controller/README.md b/ee/apps/den-controller/README.md
index e49aabec..fa73fdae 100644
--- a/ee/apps/den-controller/README.md
+++ b/ee/apps/den-controller/README.md
@@ -36,6 +36,7 @@ The script prints the exact URLs and `docker compose ... down` command to use fo
- `GOOGLE_CLIENT_ID` optional OAuth app client ID for Google sign-in
- `GOOGLE_CLIENT_SECRET` optional OAuth app client secret for Google sign-in
- `LOOPS_API_KEY` optional Loops API key used to sync newly created Den users into Loops
+- `LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL` optional Loops transactional template id for Den email verification codes
- `PORT` server port
- `CORS_ORIGINS` comma-separated list of trusted browser origins (used for Better Auth origin validation + Express CORS)
- `PROVISIONER_MODE` `stub`, `render`, or `daytona`
@@ -85,6 +86,8 @@ The script prints the exact URLs and `docker compose ... down` command to use fo
For local Daytona development, place your Daytona API credentials in `/_repos/openwork/.env.daytona` and Den will pick them up automatically, including from task worktrees.
+In local dev (`OPENWORK_DEV_MODE=1`), Den prints email verification codes to the server logs instead of sending them through Loops.
+
## Building a Daytona snapshot
If you want Daytona workers to start from a prebuilt runtime instead of a generic base image, create a snapshot and point Den at it.
diff --git a/ee/apps/den-controller/drizzle/0003_rate_limit.sql b/ee/apps/den-controller/drizzle/0003_rate_limit.sql
new file mode 100644
index 00000000..eaed29bd
--- /dev/null
+++ b/ee/apps/den-controller/drizzle/0003_rate_limit.sql
@@ -0,0 +1,8 @@
+CREATE TABLE `rate_limit` (
+ `id` varchar(255) NOT NULL,
+ `key` varchar(512) NOT NULL,
+ `count` int NOT NULL DEFAULT 0,
+ `last_request` bigint NOT NULL,
+ CONSTRAINT `rate_limit_id` PRIMARY KEY(`id`),
+ CONSTRAINT `rate_limit_key` UNIQUE(`key`)
+);
diff --git a/ee/apps/den-controller/src/auth.ts b/ee/apps/den-controller/src/auth.ts
index ff974b3b..d3a650a7 100644
--- a/ee/apps/den-controller/src/auth.ts
+++ b/ee/apps/den-controller/src/auth.ts
@@ -1,8 +1,10 @@
import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
+import { emailOTP } from "better-auth/plugins"
import { db } from "./db/index.js"
import * as schema from "./db/schema.js"
import { createDenTypeId, normalizeDenTypeId } from "./db/typeid.js"
+import { sendDenVerificationEmail } from "./email.js"
import { env } from "./env.js"
import { syncDenSignupContact } from "./loops.js"
import { ensureDefaultOrg } from "./orgs.js"
@@ -36,6 +38,10 @@ export const auth = betterAuth({
schema,
}),
advanced: {
+ ipAddress: {
+ ipAddressHeaders: ["x-forwarded-for", "x-real-ip", "cf-connecting-ip"],
+ ipv6Subnet: 64,
+ },
database: {
generateId: (options) => {
switch (options.model) {
@@ -53,24 +59,70 @@ export const auth = betterAuth({
},
},
},
- emailAndPassword: {
+ rateLimit: {
enabled: true,
- },
- databaseHooks: {
- user: {
- create: {
- after: async (user) => {
- const name = user.name ?? user.email ?? "Personal"
- const userId = normalizeDenTypeId("user", user.id)
- await Promise.all([
- ensureDefaultOrg(userId, name),
- syncDenSignupContact({
- email: user.email,
- name: user.name,
- }),
- ])
- },
+ storage: "database",
+ window: 60,
+ max: 20,
+ customRules: {
+ "/sign-in/email": {
+ window: 300,
+ max: 5,
+ },
+ "/sign-up/email": {
+ window: 3600,
+ max: 3,
+ },
+ "/email-otp/send-verification-otp": {
+ window: 3600,
+ max: 5,
+ },
+ "/email-otp/verify-email": {
+ window: 300,
+ max: 10,
+ },
+ "/request-password-reset": {
+ window: 3600,
+ max: 5,
},
},
},
+ emailVerification: {
+ sendOnSignUp: true,
+ sendOnSignIn: true,
+ afterEmailVerification: async (user) => {
+ const name = user.name ?? user.email ?? "Personal"
+ const userId = normalizeDenTypeId("user", user.id)
+ await Promise.all([
+ ensureDefaultOrg(userId, name),
+ syncDenSignupContact({
+ email: user.email,
+ name: user.name,
+ }),
+ ])
+ },
+ },
+ emailAndPassword: {
+ enabled: true,
+ autoSignIn: false,
+ requireEmailVerification: true,
+ },
+ plugins: [
+ emailOTP({
+ overrideDefaultEmailVerification: true,
+ otpLength: 6,
+ expiresIn: 600,
+ allowedAttempts: 5,
+ async sendVerificationOTP({ email, otp, type }) {
+ if (type !== "email-verification") {
+ return
+ }
+
+ void sendDenVerificationEmail({
+ email,
+ verificationCode: otp,
+ })
+ },
+ }),
+ ],
})
diff --git a/ee/apps/den-controller/src/email.ts b/ee/apps/den-controller/src/email.ts
new file mode 100644
index 00000000..d81436a8
--- /dev/null
+++ b/ee/apps/den-controller/src/email.ts
@@ -0,0 +1,63 @@
+import { env } from "./env.js"
+
+const LOOPS_TRANSACTIONAL_API_URL = "https://app.loops.so/api/v1/transactional"
+
+export async function sendDenVerificationEmail(input: {
+ email: string
+ verificationCode: string
+}) {
+ const apiKey = env.loops.apiKey
+ const transactionalId = env.loops.transactionalIdDenVerifyEmail
+ const email = input.email.trim()
+ const verificationCode = input.verificationCode.trim()
+
+ if (!email || !verificationCode) {
+ return
+ }
+
+ if (env.devMode) {
+ console.info(`[auth] dev verification code for ${email}: ${verificationCode}`)
+ return
+ }
+
+ if (!apiKey || !transactionalId) {
+ console.warn(`[auth] verification email skipped for ${email}: Loops is not configured`)
+ return
+ }
+
+ try {
+ const response = await fetch(LOOPS_TRANSACTIONAL_API_URL, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ transactionalId,
+ email,
+ dataVariables: {
+ verificationCode,
+ },
+ }),
+ })
+
+ if (response.ok) {
+ return
+ }
+
+ let detail = `status ${response.status}`
+ try {
+ const payload = (await response.json()) as { message?: string }
+ if (payload.message?.trim()) {
+ detail = payload.message
+ }
+ } catch {
+ // Ignore invalid upstream payloads.
+ }
+
+ console.warn(`[auth] failed to send verification email for ${email}: ${detail}`)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error"
+ console.warn(`[auth] failed to send verification email for ${email}: ${message}`)
+ }
+}
diff --git a/ee/apps/den-controller/src/env.ts b/ee/apps/den-controller/src/env.ts
index be28581d..0ec7986f 100644
--- a/ee/apps/den-controller/src/env.ts
+++ b/ee/apps/den-controller/src/env.ts
@@ -15,6 +15,7 @@ const schema = z.object({
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
LOOPS_API_KEY: z.string().optional(),
+ LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL: z.string().optional(),
PORT: z.string().optional(),
WORKER_PROXY_PORT: z.string().optional(),
OPENWORK_DEV_MODE: z.string().optional(),
@@ -159,6 +160,7 @@ export const env = {
},
loops: {
apiKey: optionalString(parsed.LOOPS_API_KEY),
+ transactionalIdDenVerifyEmail: optionalString(parsed.LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL),
},
port: Number(parsed.PORT ?? "8788"),
workerProxyPort: Number(parsed.WORKER_PROXY_PORT ?? "8789"),
diff --git a/ee/apps/den-controller/src/workers/daytona.ts b/ee/apps/den-controller/src/workers/daytona.ts
index d4731551..4b829862 100644
--- a/ee/apps/den-controller/src/workers/daytona.ts
+++ b/ee/apps/den-controller/src/workers/daytona.ts
@@ -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}`,
diff --git a/ee/apps/den-controller/src/workers/provisioner.ts b/ee/apps/den-controller/src/workers/provisioner.ts
index 225c97af..dd2feb24 100644
--- a/ee/apps/den-controller/src/workers/provisioner.ts
+++ b/ee/apps/den-controller/src/workers/provisioner.ts
@@ -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 = {
diff --git a/ee/apps/den-web/app/(den)/_components/auth-screen.tsx b/ee/apps/den-web/app/(den)/_components/auth-screen.tsx
index 8ae33c90..afbad3f0 100644
--- a/ee/apps/den-web/app/(den)/_components/auth-screen.tsx
+++ b/ee/apps/den-web/app/(den)/_components/auth-screen.tsx
@@ -36,6 +36,9 @@ export function AuthScreen() {
setEmail,
password,
setPassword,
+ verificationCode,
+ setVerificationCode,
+ verificationRequired,
authBusy,
authInfo,
authError,
@@ -46,6 +49,9 @@ export function AuthScreen() {
desktopRedirectBusy,
showAuthFeedback,
submitAuth,
+ submitVerificationCode,
+ resendVerificationCode,
+ cancelVerification,
beginSocialAuth,
resolveUserLandingRoute
} = useDenFlow();
@@ -70,10 +76,16 @@ export function AuthScreen() {
- {authMode === "sign-up" ? "Create your OpenWork Den account." : "Sign in to OpenWork Den."}
+ {verificationRequired
+ ? "Verify your email code."
+ : authMode === "sign-up"
+ ? "Create your OpenWork Den account."
+ : "Sign in to OpenWork Den."}
- Keep your tasks alive even when your computer sleeps.
+ {verificationRequired
+ ? "Enter the code from your inbox to finish setting up access to your cloud worker dashboard."
+ : "Keep your tasks alive even when your computer sleeps."}
@@ -97,7 +109,7 @@ export function AuthScreen() {
-
-
{authMode === "sign-in" ? "Need an account?" : "Already have an account?"}
-
setAuthMode(authMode === "sign-in" ? "sign-up" : "sign-in")}
- >
- {authMode === "sign-in" ? "Create account" : "Switch to sign in"}
-
-
+ {!verificationRequired ? (
+
+
{authMode === "sign-in" ? "Need an account?" : "Already have an account?"}
+
setAuthMode(authMode === "sign-in" ? "sign-up" : "sign-in")}
+ >
+ {authMode === "sign-in" ? "Create account" : "Switch to sign in"}
+
+
+ ) : null}
{showAuthFeedback ? (
diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx
index 2bf37d94..2bde5919 100644
--- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx
+++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx
@@ -62,6 +62,9 @@ type DenFlowContextValue = {
setEmail: (value: string) => void;
password: string;
setPassword: (value: string) => void;
+ verificationCode: string;
+ setVerificationCode: (value: string) => void;
+ verificationRequired: boolean;
authBusy: boolean;
authInfo: string;
authError: string | null;
@@ -73,6 +76,9 @@ type DenFlowContextValue = {
desktopRedirectBusy: boolean;
showAuthFeedback: boolean;
submitAuth: (event: FormEvent
) => Promise<"dashboard" | "checkout" | null>;
+ submitVerificationCode: (event: FormEvent) => Promise<"dashboard" | "checkout" | null>;
+ resendVerificationCode: () => Promise;
+ cancelVerification: () => void;
beginSocialAuth: (provider: SocialAuthProvider) => Promise;
signOut: () => Promise;
resolveUserLandingRoute: () => Promise<"/dashboard" | "/checkout" | null>;
@@ -154,6 +160,8 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
const [authMode, setAuthModeState] = useState("sign-up");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
+ const [verificationCode, setVerificationCode] = useState("");
+ const [verificationRequired, setVerificationRequired] = useState(false);
const [authBusy, setAuthBusy] = useState(false);
const [authInfo, setAuthInfo] = useState(getAuthInfoForMode("sign-up"));
const [authError, setAuthError] = useState(null);
@@ -313,10 +321,191 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
function setAuthMode(mode: AuthMode) {
setAuthModeState(mode);
+ setVerificationRequired(false);
+ setVerificationCode("");
setAuthInfo(getAuthInfoForMode(mode));
setAuthError(null);
}
+ function openVerificationStep(targetEmail: string, message?: string) {
+ setVerificationRequired(true);
+ setVerificationCode("");
+ setAuthInfo(message ?? `Enter the 6-digit code we sent to ${targetEmail}.`);
+ setAuthError(null);
+ }
+
+ function cancelVerification() {
+ setVerificationRequired(false);
+ setVerificationCode("");
+ setAuthInfo(getAuthInfoForMode(authMode));
+ setAuthError(null);
+ }
+
+ async function finalizeEmailPasswordSignIn(
+ nextMode: AuthMode,
+ trimmedEmail: string,
+ payloadOverride?: unknown,
+ ): Promise<"dashboard" | "checkout" | null> {
+ let payload = payloadOverride;
+
+ if (payload === undefined) {
+ const signInBody = {
+ email: trimmedEmail,
+ password,
+ };
+
+ const signInResult = await requestJson("/api/auth/sign-in/email", {
+ method: "POST",
+ body: JSON.stringify(signInBody)
+ });
+
+ if (!signInResult.response.ok) {
+ setAuthError(getErrorMessage(signInResult.payload, `Authentication failed with ${signInResult.response.status}.`));
+ trackPosthogEvent("den_auth_failed", {
+ mode: nextMode,
+ method: "email",
+ status: signInResult.response.status
+ });
+ return null;
+ }
+
+ payload = signInResult.payload;
+ }
+
+ const token = getToken(payload);
+ if (token) {
+ setAuthToken(token);
+ }
+
+ let authenticatedUser: AuthUser | null = null;
+ const payloadUser = getUser(payload);
+ if (payloadUser) {
+ authenticatedUser = payloadUser;
+ setUser(payloadUser);
+ setAuthInfo(`Signed in as ${payloadUser.email}.`);
+ appendEvent("success", nextMode === "sign-up" ? "Account created" : "Signed in", payloadUser.email);
+ } else {
+ const refreshed = await refreshSession(true);
+ if (refreshed) {
+ authenticatedUser = refreshed;
+ appendEvent("success", nextMode === "sign-up" ? "Account created" : "Signed in", refreshed.email);
+ } else {
+ setAuthInfo("Authentication succeeded, but session details are still syncing.");
+ }
+ }
+
+ if (authenticatedUser) {
+ identifyPosthogUser(authenticatedUser);
+ const analyticsPayload = {
+ mode: nextMode,
+ method: "email",
+ email_domain: getEmailDomain(authenticatedUser.email)
+ };
+
+ if (nextMode === "sign-up") {
+ trackPosthogEvent("den_signup_completed", analyticsPayload);
+ } else {
+ trackPosthogEvent("den_signin_completed", analyticsPayload);
+ }
+ }
+
+ if (desktopAuthRequested) {
+ setAuthInfo("Signed in. Returning to OpenWork...");
+ return null;
+ }
+
+ if (authenticatedUser && nextMode === "sign-up") {
+ return await beginSignupOnboarding(authenticatedUser, "email");
+ }
+
+ return "dashboard" as const;
+ }
+
+ async function resendVerificationCode() {
+ const trimmedEmail = email.trim();
+ if (!trimmedEmail) {
+ setAuthError("Enter your email before requesting a verification code.");
+ return;
+ }
+
+ setAuthBusy(true);
+ setAuthError(null);
+ try {
+ const { response, payload } = await requestJson("/api/auth/email-otp/send-verification-otp", {
+ method: "POST",
+ body: JSON.stringify({
+ email: trimmedEmail,
+ type: "email-verification"
+ })
+ });
+
+ if (!response.ok) {
+ setAuthError(getErrorMessage(payload, `Could not resend the code (${response.status}).`));
+ return;
+ }
+
+ setAuthInfo(`We sent a fresh verification code to ${trimmedEmail}.`);
+ appendEvent("info", "Verification code resent", trimmedEmail);
+ trackPosthogEvent("den_signup_verification_sent", {
+ method: "email",
+ email_domain: getEmailDomain(trimmedEmail),
+ });
+ } catch (error) {
+ setAuthError(error instanceof Error ? error.message : "Could not resend the verification code.");
+ } finally {
+ setAuthBusy(false);
+ }
+ }
+
+ async function submitVerificationCode(event: FormEvent) {
+ event.preventDefault();
+ const trimmedEmail = email.trim();
+ const otp = verificationCode.trim();
+ if (!trimmedEmail || !otp) {
+ setAuthError("Enter the verification code from your email.");
+ return null;
+ }
+
+ setAuthBusy(true);
+ setAuthError(null);
+ try {
+ const { response, payload } = await requestJson("/api/auth/email-otp/verify-email", {
+ method: "POST",
+ body: JSON.stringify({
+ email: trimmedEmail,
+ otp,
+ })
+ });
+
+ if (!response.ok) {
+ setAuthError(getErrorMessage(payload, `Verification failed with ${response.status}.`));
+ trackPosthogEvent("den_auth_failed", {
+ mode: authMode,
+ method: "email",
+ status: response.status,
+ reason: "verification_failed"
+ });
+ return null;
+ }
+
+ setVerificationRequired(false);
+ setVerificationCode("");
+ setAuthInfo(`Email verified for ${trimmedEmail}. Finishing sign-in...`);
+ appendEvent("success", "Email verified", trimmedEmail);
+ trackPosthogEvent("den_email_verified", {
+ method: "email",
+ email_domain: getEmailDomain(trimmedEmail),
+ });
+
+ return await finalizeEmailPasswordSignIn(authMode, trimmedEmail, payload);
+ } catch (error) {
+ setAuthError(error instanceof Error ? error.message : "Verification failed.");
+ return null;
+ } finally {
+ setAuthBusy(false);
+ }
+ }
+
async function withResolvedOpenworkCredentials(candidate: WorkerLaunch, options: { quiet?: boolean } = {}) {
const existingConnectUrl = candidate.openworkUrl?.trim() ?? "";
const existingWorkspaceId = candidate.workspaceId?.trim() ?? "";
@@ -841,6 +1030,9 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
});
if (!response.ok) {
+ if (response.status === 403) {
+ openVerificationStep(trimmedEmail, `Enter the 6-digit code we sent to ${trimmedEmail} to finish verifying your email.`);
+ }
setAuthError(getErrorMessage(payload, `Authentication failed with ${response.status}.`));
trackPosthogEvent("den_auth_failed", {
mode: authMode,
@@ -851,52 +1043,18 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
}
const token = getToken(payload);
- if (token) {
- setAuthToken(token);
- }
- let authenticatedUser: AuthUser | null = null;
- const payloadUser = getUser(payload);
- if (payloadUser) {
- authenticatedUser = payloadUser;
- setUser(payloadUser);
- setAuthInfo(`Signed in as ${payloadUser.email}.`);
- appendEvent("success", authMode === "sign-up" ? "Account created" : "Signed in", payloadUser.email);
- } else {
- const refreshed = await refreshSession(true);
- if (refreshed) {
- authenticatedUser = refreshed;
- appendEvent("success", authMode === "sign-up" ? "Account created" : "Signed in", refreshed.email);
- } else {
- setAuthInfo("Authentication succeeded, but session details are still syncing.");
- }
- }
-
- if (authenticatedUser) {
- identifyPosthogUser(authenticatedUser);
- const analyticsPayload = {
- mode: authMode,
+ if (authMode === "sign-up" && !token) {
+ setUser(null);
+ openVerificationStep(trimmedEmail, `We emailed a 6-digit verification code to ${trimmedEmail}. Enter it below to finish creating your account.`);
+ appendEvent("info", "Verification code sent", trimmedEmail);
+ trackPosthogEvent("den_signup_verification_sent", {
method: "email",
- email_domain: getEmailDomain(authenticatedUser.email)
- };
-
- if (authMode === "sign-up") {
- trackPosthogEvent("den_signup_completed", analyticsPayload);
- } else {
- trackPosthogEvent("den_signin_completed", analyticsPayload);
- }
- }
-
- if (desktopAuthRequested) {
- setAuthInfo("Signed in. Returning to OpenWork...");
+ email_domain: getEmailDomain(trimmedEmail),
+ });
return null;
}
-
- if (authenticatedUser && authMode === "sign-up") {
- return await beginSignupOnboarding(authenticatedUser, "email");
- }
-
- return "dashboard" as const;
+ return await finalizeEmailPasswordSignIn(authMode, trimmedEmail);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown network error";
setAuthError(message);
@@ -1739,6 +1897,9 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
setEmail,
password,
setPassword,
+ verificationCode,
+ setVerificationCode,
+ verificationRequired,
authBusy,
authInfo,
authError,
@@ -1750,6 +1911,9 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
desktopRedirectBusy,
showAuthFeedback,
submitAuth,
+ submitVerificationCode,
+ resendVerificationCode,
+ cancelVerification,
beginSocialAuth,
signOut,
resolveUserLandingRoute,
diff --git a/ee/apps/den-worker-proxy/src/app.ts b/ee/apps/den-worker-proxy/src/app.ts
index 1924ad6e..4853a38d 100644
--- a/ee/apps/den-worker-proxy/src/app.ts
+++ b/ee/apps/den-worker-proxy/src/app.ts
@@ -1,8 +1,9 @@
import "./load-env.js"
import { Daytona } from "@daytonaio/sdk"
import { Hono } from "hono"
-import { eq } from "@openwork-ee/den-db/drizzle"
-import { createDenDb, DaytonaSandboxTable } from "@openwork-ee/den-db"
+import { createHash } from "node:crypto"
+import { and, eq, isNull } from "@openwork-ee/den-db/drizzle"
+import { createDenDb, DaytonaSandboxTable, RateLimitTable, WorkerTokenTable } from "@openwork-ee/den-db"
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import { env } from "./env.js"
@@ -14,6 +15,9 @@ const { db } = createDenDb({
const app = new Hono()
const maxSignedPreviewExpirySeconds = 60 * 60 * 24
const signedPreviewRefreshLeadMs = 5 * 60 * 1000
+const anonymousReadRateLimit = { windowMs: 60_000, max: 60 }
+const authenticatedReadRateLimit = { windowMs: 60_000, max: 240 }
+const authenticatedWriteRateLimit = { windowMs: 60_000, max: 60 }
const publicCorsAllowMethods = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
const publicCorsAllowHeaders = [
"Authorization",
@@ -25,6 +29,9 @@ const publicCorsAllowHeaders = [
"x-opencode-directory",
]
type WorkerId = typeof DaytonaSandboxTable.$inferSelect.worker_id
+type WorkerTokenScope = typeof WorkerTokenTable.$inferSelect.scope
+
+const refreshPromises = new Map>()
function assertDaytonaConfig() {
if (!env.daytona.apiKey) {
@@ -79,6 +86,132 @@ function stripProxyHeaders(input: Headers) {
return headers
}
+function hashRateLimitId(key: string) {
+ return createHash("sha256").update(key).digest("hex")
+}
+
+function readClientIp(request: Request) {
+ const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
+ const realIp = request.headers.get("x-real-ip")?.trim()
+ return forwarded || realIp || "unknown"
+}
+
+function readBearerToken(request: Request) {
+ const header = request.headers.get("authorization")?.trim() ?? ""
+ if (!header.toLowerCase().startsWith("bearer ")) {
+ return null
+ }
+ const token = header.slice(7).trim()
+ return token || null
+}
+
+async function consumeRateLimit(input: {
+ key: string
+ max: number
+ windowMs: number
+}) {
+ const now = Date.now()
+ const rows = await db
+ .select({ count: RateLimitTable.count, lastRequest: RateLimitTable.lastRequest })
+ .from(RateLimitTable)
+ .where(eq(RateLimitTable.key, input.key))
+ .limit(1)
+
+ const current = rows[0] ?? null
+ if (!current) {
+ await db.insert(RateLimitTable).values({
+ id: hashRateLimitId(input.key),
+ key: input.key,
+ count: 1,
+ lastRequest: now,
+ })
+ return { allowed: true as const, retryAfterSeconds: 0 }
+ }
+
+ const elapsedMs = Math.max(0, now - current.lastRequest)
+ if (elapsedMs >= input.windowMs) {
+ await db
+ .update(RateLimitTable)
+ .set({ count: 1, lastRequest: now })
+ .where(eq(RateLimitTable.key, input.key))
+ return { allowed: true as const, retryAfterSeconds: 0 }
+ }
+
+ if (current.count >= input.max) {
+ return {
+ allowed: false as const,
+ retryAfterSeconds: Math.max(1, Math.ceil((input.windowMs - elapsedMs) / 1000)),
+ }
+ }
+
+ await db
+ .update(RateLimitTable)
+ .set({ count: current.count + 1, lastRequest: now })
+ .where(eq(RateLimitTable.key, input.key))
+
+ return { allowed: true as const, retryAfterSeconds: 0 }
+}
+
+async function resolveWorkerTokenScope(workerId: WorkerId, request: Request): Promise {
+ const hostToken = request.headers.get("x-openwork-host-token")?.trim() || null
+ const bearerToken = readBearerToken(request)
+ const candidateTokens: Array<{ token: string; requiredScope: WorkerTokenScope | null }> = []
+ if (hostToken) {
+ candidateTokens.push({ token: hostToken, requiredScope: "host" })
+ }
+ if (bearerToken) {
+ candidateTokens.push({ token: bearerToken, requiredScope: null })
+ }
+
+ if (candidateTokens.length === 0) {
+ return null
+ }
+
+ for (const candidate of candidateTokens) {
+ const filters = [
+ eq(WorkerTokenTable.worker_id, workerId),
+ eq(WorkerTokenTable.token, candidate.token),
+ isNull(WorkerTokenTable.revoked_at),
+ ]
+ if (candidate.requiredScope) {
+ filters.push(eq(WorkerTokenTable.scope, candidate.requiredScope))
+ }
+
+ const rows = await db
+ .select({ scope: WorkerTokenTable.scope })
+ .from(WorkerTokenTable)
+ .where(and(...filters))
+ .limit(1)
+
+ if (rows[0]?.scope) {
+ return rows[0].scope
+ }
+ }
+
+ return "invalid"
+}
+
+async function enforceProxyRateLimit(input: {
+ workerId: WorkerId
+ request: Request
+ tokenScope: WorkerTokenScope | null
+}) {
+ const method = input.request.method.toUpperCase()
+ const ip = readClientIp(input.request)
+ const authState = input.tokenScope ?? "anonymous"
+ const limit = method === "GET" || method === "HEAD"
+ ? input.tokenScope
+ ? authenticatedReadRateLimit
+ : anonymousReadRateLimit
+ : authenticatedWriteRateLimit
+
+ return consumeRateLimit({
+ key: `worker-proxy:${input.workerId}:${authState}:${method}:${ip}`,
+ max: limit.max,
+ windowMs: limit.windowMs,
+ })
+}
+
function targetUrl(baseUrl: string, requestUrl: string, workerId: WorkerId) {
const current = new URL(requestUrl)
const suffix = current.pathname.slice(`/${encodeURIComponent(workerId)}`.length) || "/"
@@ -101,23 +234,38 @@ async function getSignedPreviewUrl(workerId: WorkerId) {
return record.signed_preview_url
}
- const daytona = createDaytonaClient()
- const sandbox = await daytona.get(record.sandbox_id)
- await sandbox.refreshData()
+ const existingRefresh = refreshPromises.get(workerId)
+ if (existingRefresh) {
+ return existingRefresh
+ }
- const expiresInSeconds = normalizedSignedPreviewExpirySeconds()
- const preview = await sandbox.getSignedPreviewUrl(env.daytona.openworkPort, expiresInSeconds)
+ const refreshPromise = (async () => {
+ const daytona = createDaytonaClient()
+ const sandbox = await daytona.get(record.sandbox_id)
+ await sandbox.refreshData()
- await db
- .update(DaytonaSandboxTable)
- .set({
- signed_preview_url: preview.url,
- signed_preview_url_expires_at: signedPreviewRefreshAt(expiresInSeconds),
- region: sandbox.target,
- })
- .where(eq(DaytonaSandboxTable.worker_id, workerId))
+ const expiresInSeconds = normalizedSignedPreviewExpirySeconds()
+ const preview = await sandbox.getSignedPreviewUrl(env.daytona.openworkPort, expiresInSeconds)
- return preview.url
+ await db
+ .update(DaytonaSandboxTable)
+ .set({
+ signed_preview_url: preview.url,
+ signed_preview_url_expires_at: signedPreviewRefreshAt(expiresInSeconds),
+ region: sandbox.target,
+ })
+ .where(eq(DaytonaSandboxTable.worker_id, workerId))
+
+ return preview.url
+ })()
+
+ refreshPromises.set(workerId, refreshPromise)
+
+ try {
+ return await refreshPromise
+ } finally {
+ refreshPromises.delete(workerId)
+ }
}
async function proxyRequest(workerId: WorkerId, request: Request) {
@@ -208,7 +356,37 @@ app.all("*", async (c) => {
}
try {
- return proxyRequest(normalizeDenTypeId("worker", workerId), c.req.raw)
+ const normalizedWorkerId = normalizeDenTypeId("worker", workerId)
+ const tokenScope = await resolveWorkerTokenScope(normalizedWorkerId, c.req.raw)
+ const isWriteMethod = !["GET", "HEAD", "OPTIONS"].includes(c.req.method.toUpperCase())
+
+ if (tokenScope === "invalid" || (isWriteMethod && !tokenScope)) {
+ const headers = new Headers({ "Content-Type": "application/json" })
+ noCacheHeaders(headers)
+ applyPublicCorsHeaders(headers, c.req.raw)
+ return new Response(JSON.stringify({ error: "worker_proxy_unauthorized" }), {
+ status: 401,
+ headers,
+ })
+ }
+
+ const rateLimit = await enforceProxyRateLimit({
+ workerId: normalizedWorkerId,
+ request: c.req.raw,
+ tokenScope,
+ })
+ if (!rateLimit.allowed) {
+ const headers = new Headers({ "Content-Type": "application/json" })
+ headers.set("X-Retry-After", String(rateLimit.retryAfterSeconds))
+ noCacheHeaders(headers)
+ applyPublicCorsHeaders(headers, c.req.raw)
+ return new Response(JSON.stringify({ error: "worker_proxy_rate_limited" }), {
+ status: 429,
+ headers,
+ })
+ }
+
+ return proxyRequest(normalizedWorkerId, c.req.raw)
} catch {
const headers = new Headers({ "Content-Type": "application/json" })
noCacheHeaders(headers)
diff --git a/ee/apps/landing/README.md b/ee/apps/landing/README.md
index daadd55e..8cd2f69c 100644
--- a/ee/apps/landing/README.md
+++ b/ee/apps/landing/README.md
@@ -12,8 +12,9 @@
- `NEXT_PUBLIC_CAL_URL` - enterprise booking link
- `NEXT_PUBLIC_DEN_CHECKOUT_URL` - Polar checkout URL for the Den preorder CTA
- `LOOPS_API_KEY` - Loops API key for feedback/contact submissions
-- `LOOPS_TRANSACTIONAL_ID_APP_FEEDBACK` - Loops transactional template ID for app feedback emails
+- `LOOPS_TRANSACTIONAL_ID_APP_FEEDBACK` - Loops transactional template ID for the `Feedback email v2` transactional email
- `LOOPS_INTERNAL_FEEDBACK_EMAIL` - optional override for the internal feedback recipient (defaults to `team@openworklabs.com`)
+- `LANDING_FORM_ALLOWED_ORIGINS` - optional comma-separated origin allowlist for feedback/contact form posts
## Deploy (recommended)
@@ -25,6 +26,7 @@ This app is ready for Vercel or any Node-compatible Next.js host.
2. Build command: `pnpm --filter @openwork-ee/landing build`
3. Output: `.next`
4. Start command: `pnpm --filter @openwork-ee/landing start`
+5. Enable Vercel BotID for the project so protected form routes can reject automated submissions.
### Self-hosted
diff --git a/ee/apps/landing/app/api/_lib/security.ts b/ee/apps/landing/app/api/_lib/security.ts
new file mode 100644
index 00000000..645cedac
--- /dev/null
+++ b/ee/apps/landing/app/api/_lib/security.ts
@@ -0,0 +1,143 @@
+import { checkBotId } from "botid/server";
+
+type FixedWindowEntry = {
+ count: number;
+ resetAt: number;
+};
+
+const minimumSubmissionAgeMs = 1500;
+const maximumSubmissionAgeMs = 1000 * 60 * 60;
+const defaultAllowedOrigins = [
+ "https://openwork.software",
+ "https://www.openwork.software",
+ "http://localhost:3000",
+ "http://127.0.0.1:3000",
+ "http://localhost:3005",
+ "http://127.0.0.1:3005",
+];
+
+const store = globalThis as typeof globalThis & {
+ __openworkLandingRateLimitStore?: Map;
+};
+
+const rateLimitStore = store.__openworkLandingRateLimitStore ?? new Map();
+store.__openworkLandingRateLimitStore = rateLimitStore;
+
+function currentTime() {
+ return Date.now();
+}
+
+function readClientIp(request: Request) {
+ const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
+ const realIp = request.headers.get("x-real-ip")?.trim();
+ return forwarded || realIp || "unknown";
+}
+
+function getRequestOrigin(request: Request) {
+ try {
+ return new URL(request.url).origin;
+ } catch {
+ return "";
+ }
+}
+
+export function getAllowedOrigins(request: Request) {
+ const configured = String(process.env.LANDING_FORM_ALLOWED_ORIGINS ?? "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ return new Set([getRequestOrigin(request), ...defaultAllowedOrigins, ...configured].filter(Boolean));
+}
+
+export function validateTrustedOrigin(request: Request) {
+ const origin = request.headers.get("origin")?.trim() ?? "";
+ if (!origin) {
+ return { ok: false as const, status: 403, error: "A trusted browser origin is required." };
+ }
+ if (!getAllowedOrigins(request).has(origin)) {
+ return { ok: false as const, status: 403, error: "Origin is not allowed." };
+ }
+ return { ok: true as const, origin };
+}
+
+export function buildResponseHeaders(request: Request) {
+ const origin = request.headers.get("origin")?.trim() ?? "";
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ if (origin && getAllowedOrigins(request).has(origin)) {
+ headers["Access-Control-Allow-Origin"] = origin;
+ headers["Vary"] = "Origin";
+ }
+ return headers;
+}
+
+export function jsonResponse(request: Request, body: unknown, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: buildResponseHeaders(request),
+ });
+}
+
+export function applyFixedWindowRateLimit(input: {
+ key: string;
+ windowMs: number;
+ max: number;
+}) {
+ const now = currentTime();
+ const current = rateLimitStore.get(input.key);
+ if (!current || current.resetAt <= now) {
+ rateLimitStore.set(input.key, { count: 1, resetAt: now + input.windowMs });
+ return { ok: true as const, retryAfterSeconds: 0 };
+ }
+
+ if (current.count >= input.max) {
+ return {
+ ok: false as const,
+ retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
+ };
+ }
+
+ current.count += 1;
+ rateLimitStore.set(input.key, current);
+ return { ok: true as const, retryAfterSeconds: 0 };
+}
+
+export function rateLimitFormRequest(request: Request, route: string) {
+ return applyFixedWindowRateLimit({
+ key: `${route}:${readClientIp(request)}`,
+ windowMs: 60_000,
+ max: 5,
+ });
+}
+
+export async function verifyFormBotProtection() {
+ const result = await checkBotId();
+ if (result.isBot) {
+ return { ok: false as const, status: 403, error: "Bot traffic is not allowed for this form." };
+ }
+ return { ok: true as const };
+}
+
+export function validateAntiSpamFields(input: { website?: string; startedAt?: number | string }) {
+ if (typeof input.website === "string" && input.website.trim()) {
+ return { ok: false as const, status: 400, error: "Invalid form submission." };
+ }
+
+ const startedAt = typeof input.startedAt === "number"
+ ? input.startedAt
+ : typeof input.startedAt === "string" && input.startedAt.trim()
+ ? Number(input.startedAt)
+ : Number.NaN;
+
+ if (!Number.isFinite(startedAt)) {
+ return { ok: false as const, status: 400, error: "Missing submission timing metadata." };
+ }
+
+ const ageMs = currentTime() - startedAt;
+ if (ageMs < minimumSubmissionAgeMs || ageMs > maximumSubmissionAgeMs) {
+ return { ok: false as const, status: 400, error: "Invalid form submission timing." };
+ }
+
+ return { ok: true as const };
+}
diff --git a/ee/apps/landing/app/api/app-feedback/config.ts b/ee/apps/landing/app/api/app-feedback/config.ts
new file mode 100644
index 00000000..9a79cbca
--- /dev/null
+++ b/ee/apps/landing/app/api/app-feedback/config.ts
@@ -0,0 +1,20 @@
+const DEFAULT_INTERNAL_FEEDBACK_EMAIL = "team@openworklabs.com";
+const FEEDBACK_EMAIL_TEMPLATE_NAME = "Feedback email v2";
+
+type FeedbackEmailConfig = {
+ internalEmail: string;
+ templateName: string;
+ transactionalId: string;
+};
+
+export function getFeedbackEmailConfig(
+ env: Record,
+): FeedbackEmailConfig {
+ return {
+ internalEmail:
+ env.LOOPS_INTERNAL_FEEDBACK_EMAIL?.trim() ||
+ DEFAULT_INTERNAL_FEEDBACK_EMAIL,
+ templateName: FEEDBACK_EMAIL_TEMPLATE_NAME,
+ transactionalId: env.LOOPS_TRANSACTIONAL_ID_APP_FEEDBACK?.trim() || "",
+ };
+}
diff --git a/ee/apps/landing/app/api/app-feedback/loops-template.mjml b/ee/apps/landing/app/api/app-feedback/loops-template.mjml
new file mode 100644
index 00000000..302afcf7
--- /dev/null
+++ b/ee/apps/landing/app/api/app-feedback/loops-template.mjml
@@ -0,0 +1,133 @@
+
+
+ OpenWork App Feedback
+ New app feedback from {DATA_VARIABLE:name}
+
+
+
+
+
+
+
+
+
+
+
+ OpenWork app feedback
+
+
+
+
+
+
+
+
+ New feedback from {DATA_VARIABLE:name}
+
+
+ {DATA_VARIABLE:senderLine}
+
+
+ Submitted: {DATA_VARIABLE:submittedAtDisplay}
+
+
+
+
+
+
+
+ Issue
+
+
+
+ {DATA_VARIABLE:message}
+
+
+
+
+
+
+
+
+ App + environment
+
+
+
+ App version: {DATA_VARIABLE:appVersion}
+ Platform: {DATA_VARIABLE:platform}
+ OS: {DATA_VARIABLE:osLabel}
+
+ OpenWork server: {DATA_VARIABLE:openworkServerVersion}
+ OpenCode: {DATA_VARIABLE:opencodeVersion}
+ Orchestrator: {DATA_VARIABLE:orchestratorVersion}
+ Router: {DATA_VARIABLE:opencodeRouterVersion}
+
+
+
+
+
+
+
+
+ Context
+
+
+
+ Source: {DATA_VARIABLE:source}
+ Entrypoint: {DATA_VARIABLE:entrypoint}
+ Deployment: {DATA_VARIABLE:deployment}
+
+
+
+
+
+
+
+
+
+ OpenWork app feedback
+
+
+
+
+
diff --git a/ee/apps/landing/app/api/app-feedback/route.ts b/ee/apps/landing/app/api/app-feedback/route.ts
index 74fbfa04..4a53f552 100644
--- a/ee/apps/landing/app/api/app-feedback/route.ts
+++ b/ee/apps/landing/app/api/app-feedback/route.ts
@@ -1,4 +1,7 @@
-import { NextResponse } from "next/server";
+import { buildResponseHeaders, jsonResponse, rateLimitFormRequest, validateAntiSpamFields, validateTrustedOrigin, verifyFormBotProtection } from "../_lib/security";
+
+import { getFeedbackEmailConfig } from "./config";
+import { buildFeedbackEmailVariables } from "./template";
type FeedbackContext = {
source?: string;
@@ -18,11 +21,12 @@ type FeedbackPayload = {
name?: string;
email?: string;
message?: string;
+ website?: string;
+ startedAt?: number | string;
context?: FeedbackContext;
};
const LOOPS_TRANSACTIONAL_API_URL = "https://app.loops.so/api/v1/transactional";
-const DEFAULT_INTERNAL_FEEDBACK_EMAIL = "team@openworklabs.com";
function sanitizeValue(value: unknown, maxLength = 240) {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
@@ -63,69 +67,106 @@ function formatDiagnosticsSummary(context: ReturnType) {
}
export async function POST(request: Request) {
+ const originCheck = validateTrustedOrigin(request);
+ if (!originCheck.ok) {
+ return jsonResponse(request, { error: originCheck.error }, originCheck.status);
+ }
+
+ const rateLimit = rateLimitFormRequest(request, "app-feedback");
+ if (!rateLimit.ok) {
+ return new Response(JSON.stringify({ error: "Feedback form is temporarily rate limited." }), {
+ status: 429,
+ headers: {
+ ...buildResponseHeaders(request),
+ "X-Retry-After": String(rateLimit.retryAfterSeconds),
+ },
+ });
+ }
+
+ const botProtection = await verifyFormBotProtection();
+ if (!botProtection.ok) {
+ return jsonResponse(request, { error: botProtection.error }, botProtection.status);
+ }
+
const apiKey = process.env.LOOPS_API_KEY?.trim();
- const transactionalId =
- process.env.LOOPS_TRANSACTIONAL_ID_APP_FEEDBACK?.trim();
- const internalEmail =
- process.env.LOOPS_INTERNAL_FEEDBACK_EMAIL?.trim() ||
- DEFAULT_INTERNAL_FEEDBACK_EMAIL;
+ const { internalEmail, templateName, transactionalId } =
+ getFeedbackEmailConfig(process.env);
if (!apiKey || !transactionalId) {
- return NextResponse.json(
- { error: "App feedback is not configured on this deployment." },
- { status: 500 },
+ return jsonResponse(
+ request,
+ { error: `${templateName} is not configured on this deployment.` },
+ 500,
);
}
let payload: FeedbackPayload;
try {
- payload = (await request.json()) as FeedbackPayload;
+ const raw = await request.text();
+ if (raw.length > 8000) {
+ return jsonResponse(request, { error: "Request payload is too large." }, 413);
+ }
+ payload = JSON.parse(raw) as FeedbackPayload;
} catch {
- return NextResponse.json(
+ return jsonResponse(request,
{ error: "Invalid request payload." },
- { status: 400 },
+ 400,
);
}
+ const antiSpam = validateAntiSpamFields(payload);
+ if (!antiSpam.ok) {
+ return jsonResponse(request, { error: antiSpam.error }, antiSpam.status);
+ }
+
const message = sanitizeValue(payload.message, 5000);
const name = sanitizeValue(payload.name, 120);
const email = sanitizeValue(payload.email, 240);
if (!name) {
- return NextResponse.json(
+ return jsonResponse(request,
{ error: "Please include your name so we know who sent this." },
- { status: 400 },
+ 400,
);
}
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
- return NextResponse.json(
+ return jsonResponse(request,
{ error: "Please include a valid email so we can follow up." },
- { status: 400 },
+ 400,
);
}
if (!message) {
- return NextResponse.json(
+ return jsonResponse(request,
{ error: "Please include a short message before sending feedback." },
- { status: 400 },
+ 400,
);
}
const context = sanitizeContext(payload.context);
const diagnosticsSummary = formatDiagnosticsSummary(context);
const submittedAt = new Date().toISOString();
+ const templateVariables = buildFeedbackEmailVariables({
+ name,
+ email,
+ message,
+ submittedAt,
+ context,
+ });
if (process.env.NODE_ENV === "development") {
console.log("[DEV] Skipping Loops app feedback email", {
internalEmail,
+ templateName,
transactionalId,
message,
name,
email,
context,
+ templateVariables,
});
- return NextResponse.json({ ok: true });
+ return jsonResponse(request, { ok: true });
}
const response = await fetch(LOOPS_TRANSACTIONAL_API_URL, {
@@ -138,6 +179,7 @@ export async function POST(request: Request) {
transactionalId,
email: internalEmail,
dataVariables: {
+ ...templateVariables,
name,
email,
message,
@@ -171,8 +213,8 @@ export async function POST(request: Request) {
// Ignore invalid upstream error bodies.
}
- return NextResponse.json({ error: detail }, { status: 502 });
+ return jsonResponse(request, { error: detail }, 502);
}
- return NextResponse.json({ ok: true });
+ return jsonResponse(request, { ok: true });
}
diff --git a/ee/apps/landing/app/api/app-feedback/template.ts b/ee/apps/landing/app/api/app-feedback/template.ts
new file mode 100644
index 00000000..0aa04748
--- /dev/null
+++ b/ee/apps/landing/app/api/app-feedback/template.ts
@@ -0,0 +1,106 @@
+export type FeedbackTemplateContext = {
+ source?: string;
+ entrypoint?: string;
+ deployment?: string;
+ appVersion?: string;
+ openworkServerVersion?: string;
+ opencodeVersion?: string;
+ orchestratorVersion?: string;
+ opencodeRouterVersion?: string;
+ osName?: string;
+ osVersion?: string;
+ platform?: string;
+};
+
+type BuildFeedbackEmailVariablesInput = {
+ name: string;
+ email: string;
+ message: string;
+ submittedAt: string;
+ context: FeedbackTemplateContext;
+};
+
+function buildSummary(
+ items: Array<{ label: string; value: string | undefined }>,
+): string {
+ return items
+ .filter((item) => item.value)
+ .map((item) => `${item.label}: ${item.value}`)
+ .join("\n");
+}
+
+function joinSummarySections(sections: string[]): string {
+ return sections.filter(Boolean).join("\n\n");
+}
+
+function formatSubmittedAtDisplay(submittedAt: string): string {
+ const date = new Date(submittedAt);
+
+ if (Number.isNaN(date.getTime())) {
+ return submittedAt;
+ }
+
+ return `${new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ timeZone: "UTC",
+ }).format(date)} UTC`;
+}
+
+export function buildFeedbackEmailVariables(
+ input: BuildFeedbackEmailVariablesInput,
+) {
+ const osLabel = [input.context.osName, input.context.osVersion]
+ .filter(Boolean)
+ .join(" ");
+
+ const environmentSummary = joinSummarySections([
+ buildSummary([
+ { label: "App version", value: input.context.appVersion },
+ { label: "Platform", value: input.context.platform },
+ { label: "OS", value: osLabel },
+ ]),
+ buildSummary([
+ { label: "OpenWork server", value: input.context.openworkServerVersion },
+ { label: "OpenCode", value: input.context.opencodeVersion },
+ { label: "Orchestrator", value: input.context.orchestratorVersion },
+ { label: "Router", value: input.context.opencodeRouterVersion },
+ ]),
+ ]);
+
+ const contextSummary = buildSummary([
+ { label: "Source", value: input.context.source },
+ { label: "Entrypoint", value: input.context.entrypoint },
+ { label: "Deployment", value: input.context.deployment },
+ ]);
+
+ const submittedAtDisplay = formatSubmittedAtDisplay(input.submittedAt);
+ const senderLine = `${input.name} <${input.email}>`;
+ const plainTextBody = [
+ "OpenWork App Feedback",
+ `From: ${senderLine}`,
+ `Submitted: ${submittedAtDisplay}`,
+ "",
+ "ISSUE",
+ input.message,
+ "",
+ "APP + ENVIRONMENT",
+ environmentSummary,
+ "",
+ "CONTEXT",
+ contextSummary,
+ ].join("\n");
+
+ return {
+ senderLine,
+ submittedAtDisplay,
+ osLabel,
+ environmentSummary,
+ contextSummary,
+ plainTextBody,
+ };
+}
diff --git a/ee/apps/landing/app/api/enterprise-contact/route.ts b/ee/apps/landing/app/api/enterprise-contact/route.ts
index 7a15d402..d09e5310 100644
--- a/ee/apps/landing/app/api/enterprise-contact/route.ts
+++ b/ee/apps/landing/app/api/enterprise-contact/route.ts
@@ -1,9 +1,11 @@
-import { NextResponse } from "next/server";
+import { buildResponseHeaders, jsonResponse, rateLimitFormRequest, validateAntiSpamFields, validateTrustedOrigin, verifyFormBotProtection } from "../_lib/security";
type ContactPayload = {
fullName?: string;
companyEmail?: string;
message?: string;
+ website?: string;
+ startedAt?: number | string;
};
const LOOPS_CONTACTS_API_URL = "https://app.loops.so/api/v1/contacts/update";
@@ -83,27 +85,57 @@ function deriveCompanyFromEmail(email: string) {
}
export async function POST(request: Request) {
+ const originCheck = validateTrustedOrigin(request);
+ if (!originCheck.ok) {
+ return jsonResponse(request, { error: originCheck.error }, originCheck.status);
+ }
+
+ const rateLimit = rateLimitFormRequest(request, "enterprise-contact");
+ if (!rateLimit.ok) {
+ return new Response(JSON.stringify({ error: "Contact form is temporarily rate limited." }), {
+ status: 429,
+ headers: {
+ ...buildResponseHeaders(request),
+ "X-Retry-After": String(rateLimit.retryAfterSeconds),
+ },
+ });
+ }
+
+ const botProtection = await verifyFormBotProtection();
+ if (!botProtection.ok) {
+ return jsonResponse(request, { error: botProtection.error }, botProtection.status);
+ }
+
const apiKey = process.env.LOOPS_API_KEY?.trim();
if (!apiKey) {
- return NextResponse.json(
+ return jsonResponse(request,
{ error: "Loops is not configured on this deployment." },
- { status: 500 }
+ 500
);
}
let payload: ContactPayload;
try {
- payload = (await request.json()) as ContactPayload;
+ const raw = await request.text();
+ if (raw.length > 6000) {
+ return jsonResponse(request, { error: "Request payload is too large." }, 413);
+ }
+ payload = JSON.parse(raw) as ContactPayload;
} catch {
- return NextResponse.json(
+ return jsonResponse(request,
{ error: "Invalid request payload." },
- { status: 400 }
+ 400
);
}
+ const antiSpam = validateAntiSpamFields(payload);
+ if (!antiSpam.ok) {
+ return jsonResponse(request, { error: antiSpam.error }, antiSpam.status);
+ }
+
const validated = validatePayload(payload);
if ("error" in validated) {
- return NextResponse.json({ error: validated.error }, { status: 400 });
+ return jsonResponse(request, { error: validated.error }, 400);
}
const { firstName, lastName } = splitName(validated.fullName);
@@ -140,7 +172,7 @@ export async function POST(request: Request) {
// Ignore invalid error payloads from upstream and return a generic message.
}
- return NextResponse.json({ error: detail }, { status: 502 });
+ return jsonResponse(request, { error: detail }, 502);
}
const eventResponse = await fetch(LOOPS_EVENTS_API_URL, {
@@ -179,8 +211,8 @@ export async function POST(request: Request) {
// Ignore invalid error payloads from upstream and return a generic message.
}
- return NextResponse.json({ error: detail }, { status: 502 });
+ return jsonResponse(request, { error: detail }, 502);
}
- return NextResponse.json({ ok: true });
+ return jsonResponse(request, { ok: true });
}
diff --git a/ee/apps/landing/app/layout.tsx b/ee/apps/landing/app/layout.tsx
index d106c9e4..d57d5e15 100644
--- a/ee/apps/landing/app/layout.tsx
+++ b/ee/apps/landing/app/layout.tsx
@@ -1,6 +1,7 @@
import "./globals.css";
import { Inter, JetBrains_Mono } from "next/font/google";
import Script from "next/script";
+import { BotIdClient } from "botid/client";
const inter = Inter({
subsets: ["latin"],
@@ -31,6 +32,11 @@ export const metadata = {
}
};
+const protectedRoutes = [
+ { path: "/api/enterprise-contact", method: "POST" as const },
+ { path: "/api/app-feedback", method: "POST" as const },
+];
+
export default function RootLayout({
children
}: {
@@ -39,6 +45,7 @@ export default function RootLayout({
return (
+