From 4de9bc3451a60e60e0f4939bd1e8d8f8f8a85c31 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 13 Jun 2026 23:10:06 +0000 Subject: [PATCH] Fix conference room assistant availability --- .../board-chat-route-feature-flag.test.ts | 24 +++++++++++++++---- server/src/app.ts | 5 +++- server/src/routes/board-chat.ts | 22 ++++++++++------- ui/src/components/ChatComposer.tsx | 4 ++-- ui/src/pages/BoardChat.test.tsx | 11 ++++++++- ui/src/pages/BoardChat.tsx | 18 +++++++++----- 6 files changed, 60 insertions(+), 24 deletions(-) diff --git a/server/src/__tests__/board-chat-route-feature-flag.test.ts b/server/src/__tests__/board-chat-route-feature-flag.test.ts index 515e2428dee..23840148146 100644 --- a/server/src/__tests__/board-chat-route-feature-flag.test.ts +++ b/server/src/__tests__/board-chat-route-feature-flag.test.ts @@ -22,13 +22,17 @@ vi.mock("node:child_process", () => ({ spawn: mockSpawn })); vi.mock("../routes/authz.js", () => ({ getActorInfo: () => ({ actorId: "user-1", agentId: null, runId: null }), assertCompanyAccess: () => {}, + assertInstanceAdmin: () => {}, })); -async function createApp(deploymentMode: "local_trusted" | "authenticated" = "local_trusted") { +async function createApp( + deploymentMode: "local_trusted" | "authenticated" = "local_trusted", + deploymentExposure: "private" | "public" = "private", +) { const { boardChatRoutes } = await import("../routes/board-chat.js"); const app = express(); app.use(express.json()); - app.use("/api", boardChatRoutes({} as any, { deploymentMode })); + app.use("/api", boardChatRoutes({} as any, { deploymentMode, deploymentExposure })); return app; } @@ -55,9 +59,9 @@ describe("POST /api/board/chat/stream feature flag guard (PAP-137)", () => { expect(mockIssueService.create).not.toHaveBeenCalled(); }); - it("returns 403 DEPLOYMENT_MODE_UNSUPPORTED outside local_trusted even with the flag on", async () => { + it("returns 403 DEPLOYMENT_MODE_UNSUPPORTED for authenticated public instances", async () => { mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true }); - const app = await createApp("authenticated"); + const app = await createApp("authenticated", "public"); const res = await request(app) .post("/api/board/chat/stream") @@ -68,7 +72,7 @@ describe("POST /api/board/chat/stream feature flag guard (PAP-137)", () => { expect(mockIssueService.addComment).not.toHaveBeenCalled(); }); - it("lets requests past the guard when the flag is on (400 on missing body, not 403)", async () => { + it("lets local_trusted requests past the deployment guard when the flag is on", async () => { mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true }); const app = await createApp(); @@ -79,6 +83,16 @@ describe("POST /api/board/chat/stream feature flag guard (PAP-137)", () => { expect(res.status).toBe(400); expect(res.body).toEqual({ error: "companyId and message are required" }); }); + + it("lets authenticated private requests past the deployment guard when the flag is on", async () => { + mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true }); + const app = await createApp("authenticated", "private"); + + const res = await request(app).post("/api/board/chat/stream").send({}); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "companyId and message are required" }); + }); }); describe("board-chat client disconnect", () => { diff --git a/server/src/app.ts b/server/src/app.ts index 6658ecfa2b2..6c4a91bdbf6 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -235,7 +235,10 @@ export async function createApp( api.use(environmentRoutes(db, { pluginWorkerManager: workerManager })); api.use(executionWorkspaceRoutes(db)); api.use(goalRoutes(db)); - api.use(boardChatRoutes(db, { deploymentMode: opts.deploymentMode })); + api.use(boardChatRoutes(db, { + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + })); api.use(approvalRoutes(db, { pluginWorkerManager: workerManager })); api.use(secretRoutes(db)); api.use(costRoutes(db, { pluginWorkerManager: workerManager })); diff --git a/server/src/routes/board-chat.ts b/server/src/routes/board-chat.ts index 9fc78384f6d..14f1fbf8fdc 100644 --- a/server/src/routes/board-chat.ts +++ b/server/src/routes/board-chat.ts @@ -4,9 +4,9 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Db } from "@paperclipai/db"; -import type { DeploymentMode } from "@paperclipai/shared"; +import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import { instanceSettingsService, issueService } from "../services/index.js"; -import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; /** * Strip structured action signals (`%%ACTIONS%%{...}%%/ACTIONS%%`) from a @@ -64,7 +64,7 @@ const MAX_CONCURRENT_BOARD_CHATS = 3; export function boardChatRoutes( db: Db, - opts: { deploymentMode: DeploymentMode }, + opts: { deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure }, ) { const router = Router(); let liveBoardChats = 0; @@ -108,13 +108,16 @@ export function boardChatRoutes( } // The relay spawns the operator's local `claude` CLI with permissions - // skipped (it must run headless), so it is only safe where the requester - // IS the machine operator: local_trusted is loopback-only single-operator - // by construction (see server/src/index.ts boot guards). Refuse everywhere - // else rather than lending the server's shell to remote users. - if (opts.deploymentMode !== "local_trusted") { + // skipped (it must run headless), so it is only safe for the machine + // operator: local_trusted loopback or authenticated/private instance-admin + // board sessions. Refuse authenticated/public rather than lending the + // server's shell to internet-reachable users. + const isSupportedDeployment = + opts.deploymentMode === "local_trusted" || + (opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"); + if (!isSupportedDeployment) { res.status(403).json({ - error: "Board chat is only available on local single-operator instances", + error: "Board chat is only available on local or private operator instances", code: "DEPLOYMENT_MODE_UNSUPPORTED", }); return; @@ -131,6 +134,7 @@ export function boardChatRoutes( return; } + assertInstanceAdmin(req); // The body-supplied companyId must belong to the authenticated actor — // it scopes issue reads/writes below and is exported to the subprocess. assertCompanyAccess(req, companyId); diff --git a/ui/src/components/ChatComposer.tsx b/ui/src/components/ChatComposer.tsx index 7791e8fd862..5a2c1b24fb8 100644 --- a/ui/src/components/ChatComposer.tsx +++ b/ui/src/components/ChatComposer.tsx @@ -52,7 +52,7 @@ export interface ChatComposerProps { submitting?: boolean; /** * Send-key behavior. - * - `"enter"`: Enter submits, Shift+Enter inserts a newline (conference room default today). + * - `"enter"`: Enter submits, Shift+Enter inserts a newline. * - `"mod-enter"`: Cmd/Ctrl+Enter submits, Enter inserts a newline (recommended unified default). */ submitKey?: "enter" | "mod-enter"; @@ -149,7 +149,7 @@ export const ChatComposer = forwardRef(fu }), []); // Auto-grow the textarea up to a cap (multiline only). Single-line stays at - // one row and scrolls horizontally, matching the conference room today. + // one row and scrolls horizontally for callers that explicitly opt in. useLayoutEffect(() => { const el = textareaRef.current; if (!el || singleLine) return; diff --git a/ui/src/pages/BoardChat.test.tsx b/ui/src/pages/BoardChat.test.tsx index d8a5b181bd5..22ff19e8006 100644 --- a/ui/src/pages/BoardChat.test.tsx +++ b/ui/src/pages/BoardChat.test.tsx @@ -23,6 +23,7 @@ const mockIssuesApi = vi.hoisted(() => ({ listFeedbackVotes: vi.fn(), })); const mockDialogState = vi.hoisted(() => ({ onboardingOpen: false })); +const mockChatComposerProps = vi.hoisted(() => [] as Array>); vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi })); vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi })); @@ -51,7 +52,8 @@ vi.mock("../components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: ReactNode }) =>
{children}
, })); vi.mock("../components/ChatComposer", () => ({ - ChatComposer: forwardRef((_props, ref) => { + ChatComposer: forwardRef((props: Record, ref) => { + mockChatComposerProps.push(props); useImperativeHandle(ref, () => ({ focus: vi.fn() })); return
; }), @@ -128,6 +130,7 @@ describe("BoardChat staged typing intro", () => { mockIssuesApi.list.mockResolvedValue([BOARD_ISSUE]); mockIssuesApi.listComments.mockResolvedValue([]); mockIssuesApi.listFeedbackVotes.mockResolvedValue([]); + mockChatComposerProps.length = 0; }); afterEach(async () => { @@ -295,4 +298,10 @@ describe("BoardChat staged typing intro", () => { "bottom-[calc(5rem_+_env(safe-area-inset-bottom))]", ); }); + + it("configures the Conference Room composer to submit on Cmd/Ctrl+Enter", async () => { + await render(); + + expect(mockChatComposerProps.at(-1)?.submitKey).toBe("mod-enter"); + }); }); diff --git a/ui/src/pages/BoardChat.tsx b/ui/src/pages/BoardChat.tsx index 1c504a3cbec..e956d27ea16 100644 --- a/ui/src/pages/BoardChat.tsx +++ b/ui/src/pages/BoardChat.tsx @@ -563,6 +563,7 @@ export function BoardChat() { const res = await fetch("/api/board/chat/stream", { method: "POST", headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ companyId: selectedCompanyId, message: trimmed, @@ -573,7 +574,11 @@ export function BoardChat() { clearTimeout(fetchTimeout); if (!res.ok || !res.body) { - throw new Error("Board chat stream not available"); + const errorBody = await res.json().catch(() => null); + const message = + (errorBody as { error?: string } | null)?.error || + "Board chat stream not available"; + throw new Error(message); } setStatusText("Thinking..."); @@ -634,7 +639,9 @@ export function BoardChat() { console.error("Board chat error:", err); setStatusText(""); setErrorText( - "The board assistant is unavailable right now. Please try again in a moment.", + err instanceof Error + ? err.message + : "The board assistant is unavailable right now. Please try again in a moment.", ); } finally { setSending(false); @@ -953,9 +960,8 @@ export function BoardChat() { {/* Input — shared ChatComposer (PAP-95a), adopted bare: textarea + send. No mode chip (the room has no task lifecycle). Multiline like task comments (PAP-116): text soft-wraps and the box auto-grows instead of - clipping / showing a horizontal scrollbar. Sends on plain Enter today - (Shift+Enter for a newline); flipping to ⌘/Ctrl+Enter is pending board - confirmation. + clipping / showing a horizontal scrollbar. Sends on ⌘/Ctrl+Enter; + plain Enter inserts a newline. PAP-131 (PAP-128 A): the dock floats over the message stream so text scrolls behind the translucent glass box. The old hard black gradient @@ -972,7 +978,7 @@ export function BoardChat() { onChange={setInput} onSubmit={handleSend} placeholder="Ask anything about your company..." - submitKey="enter" + submitKey="mod-enter" surface="translucent" submitting={sending} disabled={sending}