From 0ef02a3eb521f3f355bfae66d08215f462aef8a9 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Wed, 11 Feb 2026 15:21:46 -0800 Subject: [PATCH] feat(owpenbot): scope workspace agent and split identities advanced tab --- packages/app/src/app/lib/openwork-server.ts | 18 +- packages/app/src/app/pages/identities.tsx | 86 ++++- packages/owpenbot/src/bridge.ts | 319 ++++++++++++++---- packages/owpenbot/src/health.ts | 21 +- .../test/bridge-multiworkspace.test.js | 79 ++++- packages/owpenbot/test/health-send.test.js | 71 +++- packages/server/src/server.ts | 12 +- 7 files changed, 523 insertions(+), 83 deletions(-) diff --git a/packages/app/src/app/lib/openwork-server.ts b/packages/app/src/app/lib/openwork-server.ts index 417bd0d96..ad42b1c71 100644 --- a/packages/app/src/app/lib/openwork-server.ts +++ b/packages/app/src/app/lib/openwork-server.ts @@ -214,6 +214,12 @@ export type OpenworkOwpenbotHealthSnapshot = { lastOutboundAt?: number; lastMessageAt?: number; }; + agent?: { + scope: "workspace"; + path: string; + loaded: boolean; + selected?: string; + }; }; export type OpenworkOwpenbotBindingItem = { @@ -238,6 +244,7 @@ export type OpenworkOwpenbotSendResult = { channel: string; identityId?: string; directory: string; + peerId?: string; attempted: number; sent: number; failures?: Array<{ identityId: string; peerId: string; error: string }>; @@ -1011,7 +1018,14 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s ), sendOwpenbotMessage: ( workspaceId: string, - input: { channel: "telegram" | "slack"; text: string; identityId?: string; directory?: string }, + input: { + channel: "telegram" | "slack"; + text: string; + identityId?: string; + directory?: string; + peerId?: string; + autoBind?: boolean; + }, options?: { healthPort?: number | null }, ) => { const payload = { @@ -1019,6 +1033,8 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s text: input.text, ...(input.identityId?.trim() ? { identityId: input.identityId.trim() } : {}), ...(input.directory?.trim() ? { directory: input.directory.trim() } : {}), + ...(input.peerId?.trim() ? { peerId: input.peerId.trim() } : {}), + ...(input.autoBind === true ? { autoBind: true } : {}), healthPort: options?.healthPort ?? null, }; diff --git a/packages/app/src/app/pages/identities.tsx b/packages/app/src/app/pages/identities.tsx index b460ba9b5..9af768e98 100644 --- a/packages/app/src/app/pages/identities.tsx +++ b/packages/app/src/app/pages/identities.tsx @@ -138,6 +138,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { const [slackError, setSlackError] = createSignal(null); const [expandedChannel, setExpandedChannel] = createSignal(null); + const [activeTab, setActiveTab] = createSignal<"general" | "advanced">("general"); const [agentLoading, setAgentLoading] = createSignal(false); const [agentSaving, setAgentSaving] = createSignal(false); @@ -150,6 +151,8 @@ export default function IdentitiesView(props: IdentitiesViewProps) { const [sendChannel, setSendChannel] = createSignal<"telegram" | "slack">("telegram"); const [sendDirectory, setSendDirectory] = createSignal(""); + const [sendPeerId, setSendPeerId] = createSignal(""); + const [sendAutoBind, setSendAutoBind] = createSignal(true); const [sendText, setSendText] = createSignal(""); const [sendBusy, setSendBusy] = createSignal(false); const [sendStatus, setSendStatus] = createSignal(null); @@ -228,6 +231,16 @@ export default function IdentitiesView(props: IdentitiesViewProps) { return `${days}d ago`; }); + const workspaceAgentStatus = createMemo(() => { + const agent = health()?.agent; + if (!agent) return null; + return { + path: agent.path, + loaded: agent.loaded, + selected: agent.selected ?? "", + }; + }); + const resetAgentState = () => { setAgentLoading(false); setAgentSaving(false); @@ -353,9 +366,12 @@ export default function IdentitiesView(props: IdentitiesViewProps) { channel: sendChannel(), text, ...(sendDirectory().trim() ? { directory: sendDirectory().trim() } : {}), + ...(sendPeerId().trim() ? { peerId: sendPeerId().trim() } : {}), + ...(sendAutoBind() ? { autoBind: true } : {}), }); setSendResult(result); - setSendStatus(`Dispatched ${result.sent}/${result.attempted} messages.`); + const base = `Dispatched ${result.sent}/${result.attempted} messages.`; + setSendStatus(result.reason?.trim() ? `${base} ${result.reason.trim()}` : base); } catch (error) { setSendError(formatRequestError(error)); } finally { @@ -607,6 +623,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { setSendResult(null); setReconnectStatus(null); setReconnectError(null); + setActiveTab("general"); }); onMount(() => { @@ -679,6 +696,31 @@ export default function IdentitiesView(props: IdentitiesViewProps) { +
+ + +
+ + + {/* ---- Worker status card ---- */}
@@ -1101,6 +1143,10 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
+
+ + + {/* ---- Message routing ---- */}
@@ -1128,7 +1174,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
- Advanced: reply with /dir <path> in Slack/Telegram to override the directory for a specific chat. + Advanced: reply with /dir <path> in Slack/Telegram to override the directory for a specific chat (limited to this workspace root).
@@ -1138,7 +1184,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
Messaging agent behavior
- Edit the workspace instructions used before each inbound Telegram/Slack message. + One file per workspace. Add optional first line @agent <id> to route via a specific OpenCode agent.
@@ -1146,6 +1192,14 @@ export default function IdentitiesView(props: IdentitiesViewProps) { + + {(value) => ( +
+ Active scope: workspace · status: {value().loaded ? "loaded" : "missing"} · selected agent: {value().selected || "(none)"} +
+ )} +
+
Loading agent file…
@@ -1208,7 +1262,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
Send test message
- Dispatch an outbound message via the workspace send route to validate bindings and channel wiring. + Validate outbound wiring. Use a peer ID for direct send, or leave peer ID empty to fan out by bindings in a directory.
@@ -1224,6 +1278,18 @@ export default function IdentitiesView(props: IdentitiesViewProps) { +
+ + setSendPeerId(e.currentTarget.value)} + /> +
+ + +
setSendDirectory(e.currentTarget.value)} />
+
+ +
@@ -1277,6 +1353,8 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
+
+ ); diff --git a/packages/owpenbot/src/bridge.ts b/packages/owpenbot/src/bridge.ts index 67a575b39..b7b86fe9f 100644 --- a/packages/owpenbot/src/bridge.ts +++ b/packages/owpenbot/src/bridge.ts @@ -1,7 +1,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { readFile, stat } from "node:fs/promises"; -import { join } from "node:path"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import type { Logger } from "pino"; @@ -139,6 +139,13 @@ const TYPING_INTERVAL_MS = 6000; const OWPENBOT_AGENT_FILE_RELATIVE_PATH = ".opencode/agents/owpenbot.md"; const OWPENBOT_AGENT_MAX_CHARS = 16_000; +type MessagingAgentConfig = { + filePath: string; + loaded: boolean; + selectedAgent?: string; + instructions: string; +}; + // Model presets for quick switching const MODEL_PRESETS: Record = { opus: { providerID: "anthropic", modelID: "claude-opus-4-5-20251101" }, @@ -182,7 +189,83 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri const reportStatus = reporter?.onStatus; const clients = new Map>(); const defaultDirectory = config.opencodeDirectory; - const agentPromptCache = new Map(); + const workspaceRoot = resolve(defaultDirectory || process.cwd()); + const workspaceAgentFilePath = join(workspaceRoot, OWPENBOT_AGENT_FILE_RELATIVE_PATH); + const agentPromptCache = new Map(); + let latestAgentConfig: MessagingAgentConfig = { + filePath: workspaceAgentFilePath, + loaded: false, + instructions: "", + }; + + const parseMessagingAgentFile = (content: string): { selectedAgent?: string; instructions: string } => { + const lines = content.split(/\r?\n/); + let start = 0; + while (start < lines.length && !lines[start]?.trim()) { + start += 1; + } + + let selectedAgent: string | undefined; + if (start < lines.length) { + const first = lines[start]?.trim() ?? ""; + const match = first.match(/^@agent\s+([A-Za-z0-9_.:/-]+)$/); + if (match?.[1]) { + selectedAgent = match[1]; + lines.splice(start, 1); + } + } + + const instructions = lines.join("\n").trim(); + return { ...(selectedAgent ? { selectedAgent } : {}), instructions }; + }; + + const loadMessagingAgentConfig = async (): Promise => { + const filePath = workspaceAgentFilePath; + try { + const info = await stat(filePath); + if (!info.isFile()) { + agentPromptCache.delete(filePath); + latestAgentConfig = { filePath, loaded: false, instructions: "" }; + return latestAgentConfig; + } + + const cached = agentPromptCache.get(filePath); + if (cached && cached.mtimeMs === info.mtimeMs) { + latestAgentConfig = cached.config; + return latestAgentConfig; + } + + const raw = (await readFile(filePath, "utf8")).trim(); + if (!raw) { + const next: MessagingAgentConfig = { filePath, loaded: false, instructions: "" }; + agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, config: next }); + latestAgentConfig = next; + return next; + } + + const truncated = raw.length > OWPENBOT_AGENT_MAX_CHARS ? raw.slice(0, OWPENBOT_AGENT_MAX_CHARS) : raw; + const parsed = parseMessagingAgentFile(truncated); + const next: MessagingAgentConfig = { + filePath, + loaded: Boolean(parsed.instructions || parsed.selectedAgent), + ...(parsed.selectedAgent ? { selectedAgent: parsed.selectedAgent } : {}), + instructions: parsed.instructions, + }; + agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, config: next }); + latestAgentConfig = next; + return next; + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === "ENOENT") { + agentPromptCache.delete(filePath); + latestAgentConfig = { filePath, loaded: false, instructions: "" }; + return latestAgentConfig; + } + logger.warn({ error, filePath }, "failed to load owpenbot agent file"); + latestAgentConfig = { filePath, loaded: false, instructions: "" }; + return latestAgentConfig; + } + }; const isDangerousRootDirectory = (dir: string) => { const normalized = dir.trim(); @@ -217,46 +300,6 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri return next; }; - const loadMessagingAgentPrompt = async (directory: string): Promise => { - const base = directory.trim() || defaultDirectory; - if (!base) return ""; - - const filePath = join(base, OWPENBOT_AGENT_FILE_RELATIVE_PATH); - try { - const info = await stat(filePath); - if (!info.isFile()) { - agentPromptCache.delete(filePath); - return ""; - } - - const cached = agentPromptCache.get(filePath); - if (cached && cached.mtimeMs === info.mtimeMs) { - return cached.content; - } - - const content = (await readFile(filePath, "utf8")).trim(); - if (!content) { - agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, content: "" }); - return ""; - } - - const next = - content.length > OWPENBOT_AGENT_MAX_CHARS - ? content.slice(0, OWPENBOT_AGENT_MAX_CHARS) - : content; - agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, content: next }); - return next; - } catch (error) { - const code = (error as NodeJS.ErrnoException)?.code; - if (code === "ENOENT") { - agentPromptCache.delete(filePath); - return ""; - } - logger.warn({ error, filePath }, "failed to load owpenbot agent file"); - return ""; - } - }; - const rootClient = getClient(defaultDirectory); const store = deps.store ?? new BridgeStore(config.dbPath); @@ -321,6 +364,31 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri return process.platform === "win32" ? normalized.toLowerCase() : normalized; }; + const workspaceRootNormalized = normalizeDirectory(workspaceRoot); + + const isWithinWorkspaceRoot = (candidate: string) => { + const resolved = resolve(candidate || workspaceRoot); + const relativePath = relative(workspaceRoot, resolved); + if (!relativePath) return true; + if (relativePath === ".") return true; + if (relativePath.startsWith("..") || isAbsolute(relativePath)) return false; + const boundary = workspaceRoot.endsWith(sep) ? workspaceRoot : `${workspaceRoot}${sep}`; + return resolved === workspaceRoot || resolved.startsWith(boundary); + }; + + const resolveScopedDirectory = (input: string): { ok: true; directory: string } | { ok: false; error: string } => { + const trimmed = input.trim(); + if (!trimmed) return { ok: false, error: "Directory is required." }; + const resolved = resolve(isAbsolute(trimmed) ? trimmed : join(workspaceRoot, trimmed)); + if (!isWithinWorkspaceRoot(resolved)) { + return { + ok: false, + error: `Directory must stay within workspace root: ${workspaceRootNormalized}`, + }; + } + return { ok: true, directory: normalizeDirectory(resolved) }; + }; + const formatModelLabel = (model?: ModelRef) => model ? `${model.providerID}/${model.modelID}` : null; @@ -443,6 +511,8 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri lastOutboundAt = now; }; + await loadMessagingAgentConfig(); + let stopHealthServer: (() => void) | null = null; if (!deps.disableHealthServer && config.healthPort) { stopHealthServer = startHealthServer( @@ -473,6 +543,12 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri ? { lastMessageAt: Math.max(lastInboundAt ?? 0, lastOutboundAt ?? 0) } : {}), }, + agent: { + scope: "workspace", + path: latestAgentConfig.filePath, + loaded: latestAgentConfig.loaded, + ...(latestAgentConfig.selectedAgent ? { selected: latestAgentConfig.selectedAgent } : {}), + }, }), logger, { @@ -855,7 +931,13 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri if (!peerKey || !directory) { throw new Error("peerId and directory are required"); } - const normalizedDir = normalizeDirectory(directory); + const scoped = resolveScopedDirectory(directory); + if (!scoped.ok) { + const error = new Error(scoped.error) as Error & { status?: number }; + error.status = 400; + throw error; + } + const normalizedDir = scoped.directory; store.upsertBinding(channel as ChannelName, identityId, peerKey, normalizedDir); store.deleteSession(channel as ChannelName, identityId, peerKey); ensureEventSubscription(normalizedDir); @@ -874,23 +956,107 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri store.deleteSession(channel as ChannelName, identityId, peerKey); }, - sendMessage: async (input: { channel: string; identityId?: string; directory: string; text: string }) => { + sendMessage: async (input: { + channel: string; + identityId?: string; + directory?: string; + peerId?: string; + text: string; + autoBind?: boolean; + }) => { const channelRaw = input.channel.trim().toLowerCase(); if (channelRaw !== "telegram" && channelRaw !== "slack") { throw new Error("Invalid channel"); } const channel = channelRaw as ChannelName; const identityId = input.identityId?.trim() ? normalizeIdentityId(input.identityId) : undefined; - const directory = input.directory.trim(); + const directoryInput = (input.directory ?? "").trim(); + const peerId = (input.peerId ?? "").trim(); + const autoBind = input.autoBind === true; const text = input.text ?? ""; - if (!directory) { - throw new Error("directory is required"); - } if (!text.trim()) { throw new Error("text is required"); } - const normalizedDir = normalizeDirectory(directory); + if (!directoryInput && !peerId) { + throw new Error("directory or peerId is required"); + } + + const normalizedDir = directoryInput ? (() => { + const scoped = resolveScopedDirectory(directoryInput); + if (!scoped.ok) { + const error = new Error(scoped.error) as Error & { status?: number }; + error.status = 400; + throw error; + } + return scoped.directory; + })() : ""; + + const resolveSendIdentityId = () => { + if (identityId) return identityId; + const active = Array.from(adapters.values()).find((adapter) => adapter.name === channel); + return active?.identityId; + }; + + const targetIdentityId = resolveSendIdentityId(); + if (peerId && !targetIdentityId) { + return { + channel, + directory: normalizedDir || workspaceRootNormalized, + peerId, + attempted: 0, + sent: 0, + reason: `No ${channel} adapter is running for direct send`, + }; + } + + if (peerId && targetIdentityId) { + const adapter = adapters.get(adapterKey(channel, targetIdentityId)); + if (!adapter) { + return { + channel, + directory: normalizedDir || workspaceRootNormalized, + identityId: targetIdentityId, + peerId, + attempted: 1, + sent: 0, + failures: [{ identityId: targetIdentityId, peerId, error: "Adapter not running" }], + }; + } + + if (autoBind && normalizedDir) { + store.upsertBinding(channel, targetIdentityId, peerId, normalizedDir); + store.deleteSession(channel, targetIdentityId, peerId); + ensureEventSubscription(normalizedDir); + } + + try { + await sendText(channel, targetIdentityId, peerId, text, { kind: "system", display: false }); + return { + channel, + directory: normalizedDir || workspaceRootNormalized, + identityId: targetIdentityId, + peerId, + attempted: 1, + sent: 1, + }; + } catch (error) { + return { + channel, + directory: normalizedDir || workspaceRootNormalized, + identityId: targetIdentityId, + peerId, + attempted: 1, + sent: 0, + failures: [{ + identityId: targetIdentityId, + peerId, + error: error instanceof Error ? error.message : String(error), + }], + }; + } + } + const bindings = store.listBindings({ channel, ...(identityId ? { identityId } : {}), @@ -1140,11 +1306,11 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri const identityDirectory = resolveIdentityDirectory(inbound.channel, inbound.identityId); - const boundDirectory = + const boundDirectoryCandidate = binding?.directory?.trim() || session?.directory?.trim() || identityDirectory || defaultDirectory; const hasExplicitBinding = Boolean(binding?.directory?.trim() || session?.directory?.trim() || identityDirectory); - if (!boundDirectory || (!hasExplicitBinding && isDangerousRootDirectory(boundDirectory))) { + if (!boundDirectoryCandidate || (!hasExplicitBinding && isDangerousRootDirectory(boundDirectoryCandidate))) { await sendText( inbound.channel, inbound.identityId, @@ -1155,6 +1321,13 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri return; } + const scopedBound = resolveScopedDirectory(boundDirectoryCandidate); + if (!scopedBound.ok) { + await sendText(inbound.channel, inbound.identityId, inbound.peerId, scopedBound.error, { kind: "system" }); + return; + } + const boundDirectory = scopedBound.directory; + if (!binding?.directory?.trim()) { store.upsertBinding(inbound.channel, inbound.identityId, peerKey, boundDirectory); } @@ -1200,22 +1373,33 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri startTyping(runState); try { const effectiveModel = getUserModel(inbound.channel, inbound.identityId, peerKey, config.model); - const messagingAgentPrompt = await loadMessagingAgentPrompt(boundDirectory); - const promptText = messagingAgentPrompt + const messagingAgent = await loadMessagingAgentConfig(); + const promptText = messagingAgent.instructions ? [ "You are handling a Slack/Telegram message via OpenWork.", - "Follow this workspace messaging agent file:", - messagingAgentPrompt, + `Workspace agent file: ${messagingAgent.filePath}`, + ...(messagingAgent.selectedAgent ? [`Selected OpenCode agent: ${messagingAgent.selectedAgent}`] : []), + "Follow these workspace messaging instructions:", + messagingAgent.instructions, "", "Incoming user message:", inbound.text, ].join("\n") : inbound.text; - logger.debug({ sessionID, length: inbound.text.length, model: effectiveModel }, "prompt start"); + logger.debug( + { + sessionID, + length: inbound.text.length, + model: effectiveModel, + agent: messagingAgent.selectedAgent, + }, + "prompt start", + ); const response = await getClient(boundDirectory).session.prompt({ sessionID, parts: [{ type: "text", text: promptText }], ...(effectiveModel ? { model: effectiveModel } : {}), + ...(messagingAgent.selectedAgent ? { agent: messagingAgent.selectedAgent } : {}), }); const parts = (response as { parts?: Array<{ type?: string; text?: string; ignored?: boolean }> }).parts ?? []; const textParts = parts.filter((part) => part.type === "text" && !part.ignored); @@ -1341,7 +1525,12 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri await sendText(channel, identityId, peerId, `Current directory: ${current || "(none)"}`, { kind: "system" }); return true; } - const normalized = normalizeDirectory(next); + const scoped = resolveScopedDirectory(next); + if (!scoped.ok) { + await sendText(channel, identityId, peerId, scoped.error, { kind: "system" }); + return true; + } + const normalized = scoped.directory; store.upsertBinding(channel, identityId, peerKey, normalized); store.deleteSession(channel, identityId, peerKey); ensureEventSubscription(normalized); @@ -1350,17 +1539,17 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri } if (command === "agent") { - const binding = store.getBinding(channel, identityId, peerKey); - const current = - binding?.directory?.trim() || store.getSession(channel, identityId, peerKey)?.directory?.trim() || defaultDirectory; - const resolved = current.trim() || defaultDirectory; - const filePath = join(resolved, OWPENBOT_AGENT_FILE_RELATIVE_PATH); - const loaded = await loadMessagingAgentPrompt(resolved); + const config = await loadMessagingAgentConfig(); await sendText( channel, identityId, peerId, - `Agent file: ${filePath}\nStatus: ${loaded ? "loaded" : "missing or empty"}`, + [ + `Scope: workspace`, + `Agent file: ${config.filePath}`, + `OpenCode agent: ${config.selectedAgent ?? "(none)"}`, + `Status: ${config.loaded ? "loaded" : "missing or empty"}`, + ].join("\n"), { kind: "system" }, ); return true; @@ -1368,7 +1557,7 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri // /help command if (command === "help") { - const helpText = `/opus - Claude Opus 4.5\n/codex - GPT 5.2 Codex\n/dir - bind this chat to a directory\n/dir - show current directory\n/agent - show workspace agent file path\n/model - show current\n/reset - start fresh\n/help - this`; + const helpText = `/opus - Claude Opus 4.5\n/codex - GPT 5.2 Codex\n/dir - bind this chat to a workspace directory\n/dir - show current directory\n/agent - show workspace agent scope/path\n/model - show current\n/reset - start fresh\n/help - this`; await sendText(channel, identityId, peerId, helpText, { kind: "system" }); return true; } diff --git a/packages/owpenbot/src/health.ts b/packages/owpenbot/src/health.ts index 916b51b43..4c44021a0 100644 --- a/packages/owpenbot/src/health.ts +++ b/packages/owpenbot/src/health.ts @@ -25,6 +25,12 @@ export type HealthSnapshot = { lastOutboundAt?: number; lastMessageAt?: number; }; + agent?: { + scope: "workspace"; + path: string; + loaded: boolean; + selected?: string; + }; }; export type GroupsConfigResult = { @@ -97,7 +103,9 @@ export type BindingsListResult = { export type SendMessageInput = { channel: string; identityId?: string; - directory: string; + directory?: string; + peerId?: string; + autoBind?: boolean; text: string; }; @@ -105,6 +113,7 @@ export type SendMessageResult = { channel: string; directory: string; identityId?: string; + peerId?: string; attempted: number; sent: number; failures?: Array<{ identityId: string; peerId: string; error: string }>; @@ -582,17 +591,21 @@ export function startHealthServer( const channel = typeof payload.channel === "string" ? payload.channel.trim() : ""; const identityId = typeof payload.identityId === "string" ? payload.identityId.trim() : ""; const directory = typeof payload.directory === "string" ? payload.directory.trim() : ""; + const peerId = typeof payload.peerId === "string" ? payload.peerId.trim() : ""; + const autoBind = payload.autoBind === true; const text = typeof payload.text === "string" ? payload.text : ""; - if (!channel || !directory || !text.trim()) { + if (!channel || !text.trim() || (!directory && !peerId)) { res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: false, error: "channel, directory, and text are required" })); + res.end(JSON.stringify({ ok: false, error: "channel, text, and either directory or peerId are required" })); return; } const result = await handlers.sendMessage({ channel, ...(identityId ? { identityId } : {}), - directory, + ...(directory ? { directory } : {}), + ...(peerId ? { peerId } : {}), + ...(autoBind ? { autoBind: true } : {}), text, }); res.writeHead(200, { "Content-Type": "application/json" }); diff --git a/packages/owpenbot/test/bridge-multiworkspace.test.js b/packages/owpenbot/test/bridge-multiworkspace.test.js index 941e97bd2..4e2ab077b 100644 --- a/packages/owpenbot/test/bridge-multiworkspace.test.js +++ b/packages/owpenbot/test/bridge-multiworkspace.test.js @@ -19,10 +19,10 @@ function createLoggerStub() { return base; } -test("bridge: routes sessions per peer directory binding", async () => { +test("bridge: routes sessions per peer directory binding within workspace", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-multiws-")); const wsA = path.join(dir, "ws-a"); - const wsB = path.join(dir, "ws-b"); + const wsB = path.join(wsA, "project-b"); fs.mkdirSync(wsA, { recursive: true }); fs.mkdirSync(wsB, { recursive: true }); @@ -98,14 +98,85 @@ test("bridge: routes sessions per peer directory binding", async () => { await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-B", text: `/dir ${wsB}`, raw: {} }); await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-B", text: "ping", raw: {} }); - // Ensure prompts were routed to different directories + // Ensure prompts were routed to different workspace subdirectories assert.ok(prompted.includes(wsA)); assert.ok(prompted.includes(wsB)); // Ensure output includes per-directory pong const output = sent.map((m) => `${m.peerId}:${m.text}`).join("\n"); assert.ok(output.includes("D-A:pong:ws-a")); - assert.ok(output.includes("D-B:pong:ws-b")); + assert.ok(output.includes("D-B:pong:project-b")); + + await bridge.stop(); +}); + +test("bridge: rejects /dir outside workspace root", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-multiws-")); + const wsA = path.join(dir, "ws-a"); + const outside = path.join(dir, "outside"); + fs.mkdirSync(wsA, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + + const dbPath = path.join(dir, "owpenbot.db"); + const prompted = []; + const sent = []; + + const slackAdapter = { + key: "slack:default", + name: "slack", + identityId: "default", + maxTextLength: 39_000, + async start() {}, + async stop() {}, + async sendText(peerId, text) { + sent.push({ peerId, text }); + }, + }; + + const clientFactory = (directory) => ({ + global: { health: async () => ({ healthy: true, version: "test" }) }, + session: { + create: async () => ({ id: "session-1" }), + prompt: async () => { + prompted.push(directory); + return { parts: [{ type: "text", text: "pong" }] }; + }, + }, + }); + + const bridge = await startBridge( + { + configPath: path.join(dir, "owpenbot.json"), + configFile: { version: 1 }, + opencodeUrl: "http://127.0.0.1:4096", + opencodeDirectory: wsA, + telegramBots: [], + slackApps: [], + dataDir: dir, + dbPath, + logFile: path.join(dir, "owpenbot.log"), + toolUpdatesEnabled: false, + groupsEnabled: false, + permissionMode: "allow", + toolOutputLimit: 1200, + healthPort: undefined, + logLevel: "silent", + }, + createLoggerStub(), + undefined, + { + clientFactory, + adapters: new Map([["slack:default", slackAdapter]]), + disableEventStream: true, + disableHealthServer: true, + }, + ); + + await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-OUT", text: `/dir ${outside}`, raw: {} }); + await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-OUT", text: "ping", raw: {} }); + + assert.ok(sent.some((m) => m.text.includes("Directory must stay within workspace root"))); + assert.ok(prompted.every((dirPath) => dirPath === wsA)); await bridge.stop(); }); diff --git a/packages/owpenbot/test/health-send.test.js b/packages/owpenbot/test/health-send.test.js index fa6c2be52..4061c47d7 100644 --- a/packages/owpenbot/test/health-send.test.js +++ b/packages/owpenbot/test/health-send.test.js @@ -100,7 +100,7 @@ test("health /send delivers to directory bindings", async () => { store.close(); }); -test("health /send returns 404 when no bindings exist", async () => { +test("health /send reports no-op when no bindings exist", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-health-send-")); const dbPath = path.join(dir, "owpenbot.db"); const store = new BridgeStore(dbPath); @@ -153,3 +153,72 @@ test("health /send returns 404 when no bindings exist", async () => { await bridge.stop(); store.close(); }); + +test("health /send can deliver directly with peerId", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-health-send-")); + const dbPath = path.join(dir, "owpenbot.db"); + const store = new BridgeStore(dbPath); + const healthPort = await freePort(); + + const sent = []; + const slackAdapter = { + key: "slack:default", + name: "slack", + identityId: "default", + maxTextLength: 39_000, + async start() {}, + async stop() {}, + async sendText(peerId, text) { + sent.push({ peerId, text }); + }, + }; + + const bridge = await startBridge( + { + configPath: path.join(dir, "owpenbot.json"), + configFile: { version: 1 }, + opencodeUrl: "http://127.0.0.1:4096", + opencodeDirectory: dir, + telegramBots: [], + slackApps: [], + dataDir: dir, + dbPath, + logFile: path.join(dir, "owpenbot.log"), + toolUpdatesEnabled: false, + groupsEnabled: false, + permissionMode: "allow", + toolOutputLimit: 1200, + healthPort, + logLevel: "silent", + }, + createLoggerStub(), + undefined, + { + client: { + global: { + health: async () => ({ healthy: true, version: "test" }), + }, + }, + store, + adapters: new Map([["slack:default", slackAdapter]]), + disableEventStream: true, + }, + ); + + const response = await fetch(`http://127.0.0.1:${healthPort}/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channel: "slack", peerId: "D555", text: "hello-direct" }), + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.ok, true); + assert.equal(json.peerId, "D555"); + assert.equal(json.sent, 1); + assert.equal(sent.length, 1); + assert.equal(sent[0].peerId, "D555"); + assert.equal(sent[0].text, "hello-direct"); + + await bridge.stop(); + store.close(); +}); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index b42ceaf1b..42a37efbe 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -2062,6 +2062,8 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens: const body = await readJsonBody(ctx.request); const channel = typeof body.channel === "string" ? body.channel.trim().toLowerCase() : ""; const text = typeof body.text === "string" ? body.text : ""; + const peerId = typeof body.peerId === "string" ? body.peerId.trim() : ""; + const autoBind = body.autoBind === true || body.autoBind === "true"; const directoryInput = typeof body.directory === "string" ? body.directory.trim() : ""; const directory = directoryInput || workspace.path; const healthPort = normalizeHealthPort(body.healthPort); @@ -2082,8 +2084,8 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens: if (channel !== "telegram" && channel !== "slack") { throw new ApiError(400, "invalid_channel", "channel must be 'telegram' or 'slack'"); } - if (!directory.trim()) { - throw new ApiError(400, "directory_required", "directory is required"); + if (!directory.trim() && !peerId) { + throw new ApiError(400, "directory_required", "directory is required when peerId is not provided"); } if (!text.trim()) { throw new ApiError(400, "text_required", "text is required"); @@ -2095,7 +2097,9 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens: { channel, identityId, - directory, + ...(directory.trim() ? { directory } : {}), + ...(peerId ? { peerId } : {}), + ...(autoBind ? { autoBind: true } : {}), text, }, { port, requestHost, timeoutMs: 5_000 }, @@ -2115,7 +2119,7 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens: actor: ctx.actor ?? { type: "remote" }, action: "owpenbot.send", target: `owpenbot.${channel}`, - summary: `Sent outbound ${channel} message for ${identityId}`, + summary: `Sent outbound ${channel} message for ${identityId}${peerId ? ` to ${peerId}` : ""}`, timestamp: Date.now(), });