mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Fix conference room assistant availability
This commit is contained in:
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
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user