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.

OpenWork demo 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) {

- +
+ + +
+
+
+
+ +
+
+

+ {tr("mcp.control_chrome_browser_title")} +

+

+ {tr("mcp.control_chrome_browser_hint")} +

+
    +
  1. 1. {tr("mcp.control_chrome_browser_step_one")}
  2. +
  3. 2. {tr("mcp.control_chrome_browser_step_two")}
  4. +
  5. 3. {tr("mcp.control_chrome_browser_step_three")}
  6. +
+ + {tr("mcp.control_chrome_docs")} + + +
+
+
+ +
+
+
+ +
+
+

+ {tr("mcp.control_chrome_profile_title")} +

+

+ {tr("mcp.control_chrome_profile_hint")} +

+ + + +
+ {useExistingProfile() + ? tr("mcp.control_chrome_toggle_on") + : tr("mcp.control_chrome_toggle_off")} +
+
+
+
+
+ +
+ + +
+ + + + ); +} 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. +

+
+
+ ); + }} + +
@@ -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."} +
{/* ---- Not connected to server ---- */} @@ -761,30 +913,83 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
-
- - -
+ +
+
+ + +
+ +
+
- + +
+
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). +

+
+ +
+
+
+ + + + +
+ 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. +
+ +
+
+
{/* ---- Worker status card ---- */}
@@ -1009,7 +1214,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
+ + + - {/* Text */} -
-
-

{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) {
-
+
+ + +
} > -
+
{(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) {
+
+
+
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. +
+
+ +
+ +
+ 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. -
-
- -
- -
- 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 ? (