Fix conference room assistant availability

This commit is contained in:
Dotta committed 2026-06-16 10:02:04 +00:00
1 parent d0a3ca319a
commit 4de9bc3451
6 files changed
+60 -24

No files matched your search

@@ -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", () => {
+4 -1
View File
@@ -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 }));
+13 -9
View File
@@ -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);
+2 -2
View File
@@ -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<ChatComposerHandle, ChatComposerProps>(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;
+10 -1
View File
@@ -23,6 +23,7 @@ const mockIssuesApi = vi.hoisted(() => ({
listFeedbackVotes: vi.fn(),
}));
const mockDialogState = vi.hoisted(() => ({ onboardingOpen: false }));
const mockChatComposerProps = vi.hoisted(() => [] as Array<Record<string, unknown>>);
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi }));
@@ -51,7 +52,8 @@ vi.mock("../components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../components/ChatComposer", () => ({
ChatComposer: forwardRef((_props, ref) => {
ChatComposer: forwardRef((props: Record<string, unknown>, ref) => {
mockChatComposerProps.push(props);
useImperativeHandle(ref, () => ({ focus: vi.fn() }));
return <div data-testid="chat-composer" />;
}),
@@ -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");
});
});
+12 -6
View File
@@ -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}