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 at b8ffeb973a.

**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 reviewed f84fee2605 at 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:
DottaandPaperclip authored and GitHub committed 2026-10-13 21:39:56 -05:00
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();
});
});
+2 -2
View File
@@ -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;