diff --git a/apps/app/src/app/pages/dashboard.tsx b/apps/app/src/app/pages/dashboard.tsx index a5ed2b7dd..4551b685b 100644 --- a/apps/app/src/app/pages/dashboard.tsx +++ b/apps/app/src/app/pages/dashboard.tsx @@ -1303,6 +1303,7 @@ export default function DashboardView(props: DashboardViewProps) { openworkServerClient={props.openworkServerClient} openworkReconnectBusy={props.openworkReconnectBusy} reconnectOpenworkServer={props.reconnectOpenworkServer} + restartLocalServer={props.restartLocalServer} openworkServerWorkspaceId={props.openworkServerWorkspaceId} activeWorkspaceRoot={props.activeWorkspaceRoot} developerMode={props.developerMode} diff --git a/apps/app/src/app/pages/identities.tsx b/apps/app/src/app/pages/identities.tsx index 8201f904e..e172998a4 100644 --- a/apps/app/src/app/pages/identities.tsx +++ b/apps/app/src/app/pages/identities.tsx @@ -32,6 +32,7 @@ export type IdentitiesViewProps = { openworkServerClient: OpenworkServerClient | null; openworkReconnectBusy: boolean; reconnectOpenworkServer: () => Promise; + restartLocalServer: () => Promise; openworkServerWorkspaceId: string | null; activeWorkspaceRoot: string; developerMode: boolean; @@ -86,6 +87,14 @@ function getTelegramUsernameFromResult(value: unknown): string | null { return normalized || null; } +function readMessagingEnabledFromOpenworkConfig(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + const messaging = record.messaging; + if (!messaging || typeof messaging !== "object" || Array.isArray(messaging)) return false; + return (messaging as Record).enabled === true; +} + /* ---- Brand channel icons ---- */ function TelegramIcon(props: { size?: number }) { @@ -180,6 +189,16 @@ export default function IdentitiesView(props: IdentitiesViewProps) { const [reconnectStatus, setReconnectStatus] = createSignal(null); const [reconnectError, setReconnectError] = createSignal(null); + const [messagingEnabled, setMessagingEnabled] = createSignal(false); + const [messagingSaving, setMessagingSaving] = createSignal(false); + const [messagingStatus, setMessagingStatus] = createSignal(null); + const [messagingError, setMessagingError] = createSignal(null); + const [messagingRiskOpen, setMessagingRiskOpen] = createSignal(false); + const [messagingRestartRequired, setMessagingRestartRequired] = createSignal(false); + const [messagingRestartPromptOpen, setMessagingRestartPromptOpen] = createSignal(false); + const [messagingRestartBusy, setMessagingRestartBusy] = createSignal(false); + const [messagingDisableConfirmOpen, setMessagingDisableConfirmOpen] = createSignal(false); + const [messagingRestartAction, setMessagingRestartAction] = createSignal<"enable" | "disable">("enable"); const workspaceId = createMemo(() => { const explicitId = props.openworkServerWorkspaceId?.trim() ?? ""; @@ -415,6 +434,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { setHealthError(null); setTelegramIdentitiesError(null); setSlackIdentitiesError(null); + setMessagingError(null); if (!id) { setHealth(null); @@ -432,6 +452,26 @@ export default function IdentitiesView(props: IdentitiesViewProps) { return; } + const config = await client.getConfig(id).catch(() => null); + const isModuleEnabled = readMessagingEnabledFromOpenworkConfig(config?.openwork); + setMessagingEnabled(isModuleEnabled); + + if (!isModuleEnabled) { + setMessagingRestartRequired(false); + setHealth(null); + setHealthError(null); + setTelegramIdentities([]); + setTelegramIdentitiesError(null); + setTelegramBotUsername(null); + setTelegramPairingCode(null); + setSlackIdentities([]); + setSlackIdentitiesError(null); + if (!agentDirty() && !agentSaving()) { + void loadAgentFile(); + } + return; + } + const [healthRes, tgRes, slackRes, telegramInfo] = await Promise.all([ client.getOpenCodeRouterHealth(id), client.getOpenCodeRouterTelegramIdentities(id), @@ -443,6 +483,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { if (isOpenCodeRouterSnapshot(healthRes.json)) { setHealth(healthRes.json); + setMessagingRestartRequired(false); } else { setHealth(null); if (!healthRes.ok) { @@ -452,6 +493,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { : `OpenCodeRouter health unavailable (${healthRes.status})`; setHealthError(message); } + setMessagingRestartRequired(true); } if (isOpenCodeRouterIdentities(tgRes)) { @@ -484,6 +526,9 @@ export default function IdentitiesView(props: IdentitiesViewProps) { setHealthError(message); setTelegramIdentitiesError(message); setSlackIdentitiesError(message); + if (messagingEnabled()) { + setMessagingRestartRequired(true); + } } finally { setRefreshing(false); } @@ -505,6 +550,95 @@ export default function IdentitiesView(props: IdentitiesViewProps) { setReconnectStatus("Reconnected."); }; + const enableMessagingModule = async () => { + if (messagingSaving()) return; + if (!serverReady()) return; + const id = workspaceId(); + if (!id) return; + const client = openworkServerClient(); + if (!client) return; + + setMessagingSaving(true); + setMessagingStatus(null); + setMessagingError(null); + try { + await client.patchConfig(id, { + openwork: { + messaging: { + enabled: true, + }, + }, + }); + setMessagingEnabled(true); + setMessagingRestartRequired(true); + setMessagingRiskOpen(false); + setMessagingRestartAction("enable"); + setMessagingRestartPromptOpen(true); + setMessagingStatus("Messaging enabled. Restart this worker to apply before configuring channels."); + await refreshAll({ force: true }); + } catch (error) { + setMessagingError(formatRequestError(error)); + } finally { + setMessagingSaving(false); + } + }; + + const disableMessagingModule = async () => { + if (messagingSaving()) return; + if (!serverReady()) return; + const id = workspaceId(); + if (!id) return; + const client = openworkServerClient(); + if (!client) return; + + setMessagingSaving(true); + setMessagingStatus(null); + setMessagingError(null); + try { + await client.patchConfig(id, { + openwork: { + messaging: { + enabled: false, + }, + }, + }); + setMessagingEnabled(false); + setMessagingDisableConfirmOpen(false); + setMessagingRestartRequired(true); + setMessagingRestartAction("disable"); + setMessagingRestartPromptOpen(true); + setMessagingStatus("Messaging disabled. Restart this worker to stop the messaging sidecar."); + await refreshAll({ force: true }); + } catch (error) { + setMessagingError(formatRequestError(error)); + } finally { + setMessagingSaving(false); + } + }; + + const restartMessagingWorker = async () => { + if (messagingRestartBusy()) return; + setMessagingRestartBusy(true); + setMessagingError(null); + setMessagingStatus(null); + try { + const ok = await props.restartLocalServer(); + if (!ok) { + setMessagingError("Restart failed. Please restart the worker from Settings and try again."); + return; + } + setMessagingRestartPromptOpen(false); + setMessagingRestartRequired(false); + setMessagingStatus("Worker restarted. Refreshing messaging status..."); + await refreshAll({ force: true }); + setMessagingStatus("Worker restarted."); + } catch (error) { + setMessagingError(formatRequestError(error)); + } finally { + setMessagingRestartBusy(false); + } + }; + const upsertTelegram = async (access: "public" | "private") => { if (telegramSaving()) return; if (!serverReady()) return; @@ -689,6 +823,16 @@ export default function IdentitiesView(props: IdentitiesViewProps) { setSendResult(null); setReconnectStatus(null); setReconnectError(null); + setMessagingEnabled(false); + setMessagingSaving(false); + setMessagingStatus(null); + setMessagingError(null); + setMessagingRiskOpen(false); + setMessagingRestartRequired(false); + setMessagingRestartPromptOpen(false); + setMessagingRestartBusy(false); + setMessagingDisableConfirmOpen(false); + setMessagingRestartAction("enable"); setActiveTab("general"); setExpandedChannel("telegram"); }); @@ -744,6 +888,12 @@ export default function IdentitiesView(props: IdentitiesViewProps) { {(value) =>
{value()}
}
+ + {(value) =>
{value()}
} +
+ + {(value) =>
{value()}
} +
{/* ---- Not connected to server ---- */} @@ -763,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 ---- */}
@@ -1291,7 +1494,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { - + {/* ---- Message routing ---- */}
@@ -1512,6 +1715,56 @@ export default function IdentitiesView(props: IdentitiesViewProps) { + { + if (messagingSaving()) return; + setMessagingRiskOpen(false); + }} + onConfirm={() => { + void enableMessagingModule(); + }} + /> + + { + if (messagingRestartBusy()) return; + setMessagingRestartPromptOpen(false); + }} + onConfirm={() => { + void restartMessagingWorker(); + }} + /> + + { + if (messagingSaving()) return; + setMessagingDisableConfirmOpen(false); + }} + onConfirm={() => { + void disableMessagingModule(); + }} + /> + , key: string, @@ -2618,6 +2628,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"); } @@ -3339,6 +3491,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)", @@ -6677,13 +6830,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,