mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 06:02:18 +02:00
fix(voice): accept authenticated Cloud callers (#16254)
## Thinking Path > - Paperclip manages agent work through company-scoped tasks. > - Speko voice connections bind a caller to that work. > - Private voice routes must recognize an authenticated human caller. > - Cloud sign-in uses a trusted Cloud actor, rather than a local session actor. > - The voice caller check rejected that existing authentication type. > - This pull request accepts authenticated Cloud callers and retains the current access checks. > - Cloud users can load call history and start private voice sessions. ## Linked Issues or Issue Description Refs #15727, #15728, #15729. **What happened?** A signed-in Cloud user sees “Sign in to start a private voice conversation” when the Speko connection loads call activity. The same check prevents the user from starting a private voice session. **Expected behavior** The trusted Cloud user identity should have the same voice caller access as a local authenticated session. The current company and user identity checks must still apply. **Steps to reproduce** 1. Sign in to a Cloud workspace with experimental chat connectors enabled. 2. Connect a Speko persona to an existing agent. 3. Open its call activity, or start a browser voice session. 4. Observe the sign-in rejection despite the authenticated workspace session. **Paperclip version or commit** Reproduced on the voice route in master atb8ffeb973a. **Deployment mode** Paperclip Cloud with trusted tenant authentication. ## What Changed - Accept `cloud_tenant` and `session` actors with a user ID as private voice callers. - Use current company membership for Cloud owners, including owners with temporary administrator access. Retain local administrator authority, company isolation, and credential-source restrictions. - Add route regressions for call history, session creation, administrator authority, missing identities, company boundaries, and rejected caller types. - Add Cloud authentication qualification steps to the Speko runbook. ## Verification - Before the fix: 2 Cloud regressions failed; 9 boundary and local-user tests passed. - After the fix: all 100 voice caller and real-database persistence regressions passed. The Cloud-owner route test covers history, session creation, persisted caller authority, and membership revocation. - Repository typecheck and build passed with the repository’s pinned dependencies. - All current-head CI gates passed. Greptile reviewedf84fee2605at 5/5 with no remaining findings. The repaired local full repository test run is still in progress. The first local full-suite attempt failed on missing native database library aliases; those aliases were repaired only in the isolated test dependency. No skipped run is counted as database proof. - On Cloud, verify that signed-in call activity loads and private session creation reaches media setup. Real conversation and spoken playback acceptance remain separate. ## Risks The change accepts an existing trusted Cloud actor type. Authentication still runs in the current middleware. Raw Cloud headers, board API keys, MCP OAuth, Cloud control credentials, and agent callers cannot establish private voice caller authority. No migration, UI change, credential, transcript, or lockfile is included. ## Model Used OpenAI Codex, GPT-6 family, with tool use and code execution. The exact serving variant and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
75189b8762
commit
40ecacff4c
4 files changed
+140
-3
No files matched your search
@@ -17,6 +17,20 @@ unchanged. See the [live-call push qualification](2026-10-09-speko-pr-qualificat
|
||||
for the real-provider text-input proof, timings, reproduction and remaining
|
||||
PSTN/interruption qualification.
|
||||
|
||||
## Cloud caller authentication (2026-10-13)
|
||||
|
||||
Private voice routes accept both local authenticated sessions and trusted Cloud
|
||||
user sessions. Cloud callers keep their authenticated user ID and current
|
||||
company scope. Cloud owners use current company membership even when temporary
|
||||
Cloud administrator access is enabled; they do not need a persisted local
|
||||
instance-admin role. Board API keys, MCP OAuth callers, Cloud control credentials,
|
||||
and raw Cloud user headers do not create private caller authority.
|
||||
|
||||
On a Cloud workspace, verify that the signed-in operator can load call history
|
||||
and start a browser session. Repeat with a user outside the company and confirm
|
||||
access is denied. Provider configuration alone does not qualify conversation
|
||||
or spoken result delivery.
|
||||
|
||||
## October task-parity qualification
|
||||
|
||||
Updated 2026-10-07: September's browser proof is historical. The current task-parity
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { voiceSessionRoutes } from "../routes/voice-sessions.js";
|
||||
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -7,7 +11,7 @@ import { VOICE_RESULT_NOTIFICATION } from "@paperclipai/shared";
|
||||
import { createHash, createHmac, randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { chatActions, principalPermissionGrants, environments, chatVoiceInboundCalls, chatVoicePhoneLines, chatVoiceReports, heartbeatRuns, toolProfileBindings, chatVoiceCallbacks, agentWakeupRequests, issueThreadInteractions, issueQuestionResponseDeliveries, issueComments, agents, chatConversations, chatDeliveries, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, chatVoiceToolCalls, companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, issues, toolApplications, toolConnections, toolConnectionInstalls } from "@paperclipai/db";
|
||||
import { chatActions, instanceUserRoles, principalPermissionGrants, environments, chatVoiceInboundCalls, chatVoicePhoneLines, chatVoiceReports, heartbeatRuns, toolProfileBindings, chatVoiceCallbacks, agentWakeupRequests, issueThreadInteractions, issueQuestionResponseDeliveries, issueComments, agents, chatConversations, chatDeliveries, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, chatVoiceToolCalls, companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, issues, toolApplications, toolConnections, toolConnectionInstalls } from "@paperclipai/db";
|
||||
import { configureSpekoSessionTools } from "../services/voice/speko-tool-setup.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { chatChannelService } from "../services/chat-channels.js";
|
||||
@@ -522,6 +526,34 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
return { ...f, service, transport, credentials, input };
|
||||
}
|
||||
|
||||
it("loads history and starts calls for Cloud owners without local admin roles, retaining membership revocation", async () => {
|
||||
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
||||
const f = await nativeServiceFixture();
|
||||
await db.update(companyMemberships).set({ membershipRole: "owner" }).where(eq(companyMemberships.principalId, f.callerId));
|
||||
expect(await db.select().from(instanceUserRoles).where(eq(instanceUserRoles.userId, f.callerId))).toEqual([]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", source: "cloud_tenant", userId: f.callerId, isInstanceAdmin: true, companyIds: [f.companyId] };
|
||||
next();
|
||||
});
|
||||
app.use("/api", voiceSessionRoutes(db, f.service));
|
||||
app.use(errorHandler);
|
||||
const historyPath = `/api/companies/${f.companyId}/voice-history/${f.endpointId}`;
|
||||
const history = await request(app).get(historyPath).expect(200);
|
||||
expect(history.body).toHaveLength(1);
|
||||
const started = await request(app).post(`/api/companies/${f.companyId}/voice-sessions`)
|
||||
.send({ endpointId: f.endpointId, issueId: f.issueId, idempotencyKey: randomUUID() }).expect(201);
|
||||
const [persisted] = await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, started.body.session.id));
|
||||
expect(persisted).toMatchObject({ callerId: f.callerId, callerAuthority: "member", companyId: f.companyId });
|
||||
expect(f.transport.createBrowserSession).toHaveBeenCalledTimes(1);
|
||||
await db.update(companyMemberships).set({ status: "removed" }).where(eq(companyMemberships.principalId, f.callerId));
|
||||
await request(app).get(historyPath).expect(403);
|
||||
await request(app).post(`/api/companies/${f.companyId}/voice-sessions`)
|
||||
.send({ endpointId: f.endpointId, issueId: f.issueId, idempotencyKey: randomUUID() }).expect(403);
|
||||
expect(f.transport.createBrowserSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
async function pushFixture() {
|
||||
await instanceSettingsService(db).updateExperimental({enableChatConnectors: true});
|
||||
const f = await nativeServiceFixture();
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import type { VoiceSessionService } from "../services/voice/voice-session-service.js";
|
||||
import { voiceSessionRoutes } from "./voice-sessions.js";
|
||||
|
||||
vi.mock("../services/instance-settings.js", () => ({
|
||||
instanceSettingsService: () => ({ getExperimental: async () => ({ enableChatConnectors: true }) }),
|
||||
}));
|
||||
|
||||
const companyId = "11111111-1111-4111-8111-111111111111";
|
||||
const endpointId = "22222222-2222-4222-8222-222222222222";
|
||||
const idempotencyKey = "33333333-3333-4333-8333-333333333333";
|
||||
const actor: Express.Request["actor"] = {
|
||||
type: "board", source: "session", userId: "signed-in-user", companyIds: [companyId],
|
||||
};
|
||||
const service = { history: vi.fn(), start: vi.fn() };
|
||||
|
||||
function app(currentActor = actor) {
|
||||
const server = express();
|
||||
server.use(express.json());
|
||||
server.use((req, _res, next) => { req.actor = currentActor; next(); });
|
||||
server.use("/api", voiceSessionRoutes({} as Db, service as unknown as VoiceSessionService));
|
||||
server.use(errorHandler);
|
||||
return server;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
service.history.mockResolvedValue([]);
|
||||
service.start.mockResolvedValue({ session: { id: "test-session" } });
|
||||
});
|
||||
|
||||
describe("voice caller authentication", () => {
|
||||
it.each(["session", "cloud_tenant"] as const)("allows signed-in %s users to read calls and start a session", async (source) => {
|
||||
const server = app({ ...actor, source });
|
||||
await request(server).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200, []);
|
||||
expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: actor.userId, authority: "member" });
|
||||
await request(server).post(`/api/companies/${companyId}/voice-sessions`)
|
||||
.send({ endpointId, idempotencyKey }).expect(200);
|
||||
expect(service.start).toHaveBeenCalledWith(expect.objectContaining({ companyId, endpointId, caller: { id: actor.userId, authority: "member" } }));
|
||||
});
|
||||
|
||||
it("keeps Cloud owners company-scoped without a persisted local admin role", async () => {
|
||||
await request(app({ ...actor, source: "cloud_tenant", isInstanceAdmin: true }))
|
||||
.get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200);
|
||||
expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: actor.userId, authority: "member" });
|
||||
});
|
||||
|
||||
it("preserves local authenticated administrator authority", async () => {
|
||||
await request(app({ ...actor, isInstanceAdmin: true }))
|
||||
.get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200);
|
||||
expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: actor.userId, authority: "instance_admin" });
|
||||
});
|
||||
|
||||
it("preserves local trusted callers", async () => {
|
||||
await request(app({ type: "board", source: "local_implicit" }))
|
||||
.get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(200);
|
||||
expect(service.history).toHaveBeenCalledWith(companyId, endpointId, { id: "local-board", authority: "local_board" });
|
||||
});
|
||||
|
||||
it.each(["board_key", "mcp_oauth", "cloud_control"] as const)("rejects %s credentials as private human callers", async (source) => {
|
||||
await request(app({ ...actor, source })).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(403);
|
||||
expect(service.history).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["session", "cloud_tenant"] as const)("requires a user identity for %s callers", async (source) => {
|
||||
await request(app({ ...actor, source, userId: undefined })).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(403);
|
||||
expect(service.history).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains Cloud company isolation", async () => {
|
||||
const server = app({ ...actor, source: "cloud_tenant", companyIds: [] });
|
||||
await request(server).get(`/api/companies/${companyId}/voice-history/${endpointId}`).expect(403);
|
||||
await request(server).post(`/api/companies/${companyId}/voice-sessions`).send({ endpointId, idempotencyKey }).expect(403);
|
||||
expect(service.history).not.toHaveBeenCalled();
|
||||
expect(service.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not authenticate raw Cloud headers or agent callers", async () => {
|
||||
await request(app({ type: "none", source: "none" }))
|
||||
.get(`/api/companies/${companyId}/voice-history/${endpointId}`)
|
||||
.set("x-paperclip-cloud-user-id", actor.userId!).expect(403);
|
||||
await request(app({ type: "agent", source: "agent_key", companyId }))
|
||||
.post(`/api/companies/${companyId}/voice-sessions`).send({ endpointId, idempotencyKey }).expect(403);
|
||||
expect(service.history).not.toHaveBeenCalled();
|
||||
expect(service.start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,8 @@ import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
function caller(req: Request): VoiceCaller {
|
||||
assertBoard(req);
|
||||
if (req.actor.source === "local_implicit") return { id: "local-board", authority: "local_board" };
|
||||
if (!req.actor.userId || req.actor.source !== "session") throw forbidden("Sign in to start a private voice conversation");
|
||||
return { id: req.actor.userId, authority: req.actor.isInstanceAdmin ? "instance_admin" : "member" };
|
||||
if (!req.actor.userId || !["session", "cloud_tenant"].includes(req.actor.source ?? "")) throw forbidden("Sign in to start a private voice conversation");
|
||||
return { id: req.actor.userId, authority: req.actor.source === "session" && req.actor.isInstanceAdmin ? "instance_admin" : "member" };
|
||||
}
|
||||
function company(req: Request, requireAccess = true) {
|
||||
const id = req.params.companyId as string;
|
||||
|
||||
Reference in new issue
Block a user