mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Third PR: depends on #15728 and #15727. The user has reviewed and authorized merging this experimental feature stack. ## Thinking Path > - Paperclip manages agent work through tasks and governed connections. > - Chat connections already bind external conversations to those tasks. > - Speko handles voice and telephony, while the selected Paperclip agent does the work. > - A call needs durable session authority, ordered follow-ups, and safe result delivery. > - This PR adds repeatable qualification for the Speko voice task integration. > - This PR depends on #15728, which depends on #15727. Speko stays experimental. ## Linked Issues or Issue Description **Problem or motivation** Phone and browser voice conversations cannot use the chat connection task lifecycle. A separate voice runtime is needed to preserve task authority, retries, and reply history. **Proposed solution** Add company-scoped voice sessions, signed callbacks, durable replies, inbound intake, and an agent-facing outbound-call tool. Reuse existing tasks, queues, secrets, and publications. **Roadmap alignment** This extends the experimental chat connector entry points under Agent Chat and Connected Apps. The project owner requested this feature. Refs #11418 for the original provider catalog proposal. This implementation uses the newer chat connection infrastructure rather than an MCP catalog-only integration. Refs #15721 for remote OpenCode startup. ## What Changed - Add a real Paperclip server/database E2E fixture that stubs only external Speko transport and the execution provider. - Add authenticated multi-user and cross-company qualification journeys. - Add Storybook interaction, accessibility, responsive, and visual-suite integration. - Add an E01–E16 runbook, component-to-story inventory, and current redacted qualification report. - Keep historical private call traces out of the PR. Provider proof tools are opt-in. ## Verification - All 21 standalone proof tests and browser client/server syntax checks pass. They cover signatures, redaction, cleanup retry, final audio saves, bounded report transport and late server write ordering. No live calls were placed. - All 27 focused UI/controller regressions, UI typecheck, token gates, and 28 map/catalog structural tests pass. The shared connection cache test covers pause/resume invalidation during the 30-second fresh period. - Storybook builds 157 Speko production-component stories. The complete prior 156-story set passed all 624 checks on UI source249cbc616a. After the final cache repair, all 152 affected page/launcher checks passed across both themes, 390/1200 widths and reduced motion. Real microphone access and external provider requests are blocked. - Hosted Storybook origin isolation and representative narrow-screen stories pass. Provider requests remain blocked. - Source annotations follow the current generated-catalog contract. No generated catalog or lockfile is included. Three inherited GitHub-heading tests passed after master reconciliation. - Commit905bc5da5dhas 55 passing checks, including required verify/E2E gates, and a fresh Greptile 5/5 review with zero unresolved findings. Local fixture success does not establish provider or spoken-playback acceptance. - The separate integrated browser run passed two journeys and failed browser answer retrieval. Real browser/telephone playback and timing acceptance remain outstanding. See the dated qualification report and E01–E16 runbook. Speko stays experimental. ## Risks - Voice sessions add migrations and provider-dependent lifecycle behavior. Migrations preserve existing chat and AI transports. - Callback origins must be reachable HTTPS. Provider keys and signing secrets remain vaulted. - Unverified callers receive task-scoped low-trust authority. Caller ID does not grant private task access. - Outbound telephony incurs provider charges. Provider acceptance is not proof that the caller heard an answer. - Keep the feature behind the existing experimental chat setting until the real browser, inbound, outbound, and delayed-result acceptance matrix passes. ## Model Used OpenAI Codex, GPT-6, with tool use and code execution. The exact serving model ID 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 - [ ] 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>
170 lines
9.1 KiB
JavaScript
170 lines
9.1 KiB
JavaScript
/**
|
|
* Provider feasibility probe only. No Paperclip access, real tasks, or production
|
|
* credentials. A synthetic result is withheld for 60 seconds to test whether the
|
|
* hosted voice worker keeps fetching it without another spoken user prompt.
|
|
*/
|
|
import { createHash, randomInt } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import { Webhook } from "standardwebhooks";
|
|
|
|
export const TOOL_PATH = "/speko-proof/tool";
|
|
export const DELAY_MS = 60_000;
|
|
export const WAIT_MS = 2_500;
|
|
export const BODY_LIMIT = 32 * 1024;
|
|
const MAX_CALLS = 500;
|
|
const TTL_MS = 15 * 60_000;
|
|
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
const boundedId = (value) => typeof value === "string" && /^[A-Za-z0-9_:-]{1,200}$/.test(value);
|
|
|
|
export const PROOF_PROMPT = `You are a voice transport feasibility test, not a real company agent.
|
|
When the caller asks to start the test, call submit_request with their request.
|
|
Acknowledge that the work is running. Call get_updates with cursor 0 immediately.
|
|
When it returns pending, call it again using the returned cursor, without waiting
|
|
for the caller to ask. It waits briefly on the server. Do not claim completion
|
|
until get_updates returns a completed result. Do not invent a verification phrase.
|
|
Keep listening and allow the caller to interrupt while the work runs. When the
|
|
caller adds an instruction, call submit_request again; it updates the same job.
|
|
Continue get_updates after an interruption. Read the completed result aloud once.
|
|
Never interpret a pending tool response or acknowledgment as the completed result.
|
|
After the result, stop polling and let the caller end the test.`;
|
|
|
|
export function toolDefinitions(publicOrigin) {
|
|
const origin = new URL(publicOrigin);
|
|
if (origin.protocol !== "https:" || origin.username || origin.password || origin.search || origin.hash || origin.pathname !== "/") {
|
|
throw new Error("Public origin must be HTTPS without credentials, path, query, or fragment");
|
|
}
|
|
const source = { kind: "webhook", url: `${origin.origin}${TOOL_PATH}`, method: "POST", responseMode: "sync", timeoutMs: 3_500 };
|
|
return [
|
|
{
|
|
name: "submit_request",
|
|
description: "Start the delayed test or add an instruction to its existing job. Returns promptly; never wait for completion here.",
|
|
parameters: { type: "object", properties: { text: { type: "string", maxLength: 4_000 } }, required: ["text"], additionalProperties: false },
|
|
source,
|
|
},
|
|
{
|
|
name: "get_updates",
|
|
description: "Retrieve the delayed result. While pending, call again without waiting for another user message. Read a completed result aloud once.",
|
|
parameters: { type: "object", properties: { cursor: { type: "integer", minimum: 0, maximum: 1 } }, required: ["cursor"], additionalProperties: false },
|
|
source,
|
|
},
|
|
];
|
|
}
|
|
|
|
function validateEnvelope(value) {
|
|
if (!isRecord(value) || !boundedId(value.session_id) || !boundedId(value.tool_call_id)
|
|
|| value.idempotency_key !== `${value.session_id}:${value.tool_call_id}` || !isRecord(value.args)) return false;
|
|
if (value.tool === "submit_request") {
|
|
return Object.keys(value.args).length === 1 && typeof value.args.text === "string"
|
|
&& value.args.text.trim().length > 0 && value.args.text.length <= 4_000;
|
|
}
|
|
return value.tool === "get_updates" && Object.keys(value.args).length === 1
|
|
&& Number.isInteger(value.args.cursor) && value.args.cursor >= 0 && value.args.cursor <= 1;
|
|
}
|
|
|
|
export function createProof({ signingSecret, now = Date.now, wait = sleep, delayMs = DELAY_MS, onEvent = () => {} }) {
|
|
// Deliberately support one explicit, current-format secret; no ambiguous
|
|
// legacy decoding fallbacks. Rotation is a new isolated probe run.
|
|
if (!/^whsec_[A-Za-z0-9+/]{43}=$/.test(signingSecret ?? "")) throw new Error("A generated 32-byte Standard Webhooks secret is required");
|
|
const verifier = new Webhook(signingSecret);
|
|
const createdAt = now();
|
|
const receipts = new Map();
|
|
let sessionId;
|
|
let startedAt;
|
|
let requests = 0;
|
|
let result;
|
|
let closed = false;
|
|
const events = [];
|
|
|
|
function emit(kind, extra = {}) {
|
|
const event = { kind, elapsedMs: now() - createdAt, ...extra };
|
|
events.push(event);
|
|
onEvent(event);
|
|
}
|
|
|
|
async function execute(envelope) {
|
|
if (envelope.tool === "submit_request") {
|
|
if (result) return { status: 409, body: { error: "proof_already_completed" } };
|
|
const first = startedAt === undefined;
|
|
if (first) startedAt = now();
|
|
requests += 1;
|
|
// Never retain the caller's text, even in the local evidence file.
|
|
emit(first ? "request_accepted" : "followup_accepted", { requestCount: requests });
|
|
return { status: 200, body: { status: "pending", cursor: 0, requestCount: requests, message: "The test is running. Retrieve updates while we continue talking." } };
|
|
}
|
|
if (startedAt === undefined) {
|
|
emit("tool_rejected", { tool: "get_updates", reason: "start_request_first" });
|
|
return { status: 409, body: { error: "start_request_first" } };
|
|
}
|
|
if (envelope.args.cursor === 1 && !result) return { status: 400, body: { error: "cursor_ahead_of_result" } };
|
|
const remaining = startedAt + delayMs - now();
|
|
if (!result && remaining > 0) await wait(Math.min(WAIT_MS, remaining));
|
|
if (closed || now() - createdAt >= TTL_MS) return { status: 410, body: { error: "proof_expired" } };
|
|
if (!result && now() >= startedAt + delayMs) {
|
|
result = { cursor: 1, text: `Paperclip proof complete. Verification number ${randomInt(1_000, 10_000)}. Follow-up count ${requests - 1}.` };
|
|
emit("result_ready", { requestCount: requests, jobElapsedMs: now() - startedAt });
|
|
}
|
|
const updates = result && envelope.args.cursor < result.cursor ? [result] : [];
|
|
emit(updates.length ? "result_returned_to_tool" : "poll_returned", { cursor: result?.cursor ?? 0 });
|
|
return { status: 200, body: { status: result ? "completed" : "pending", cursor: result?.cursor ?? 0, updates } };
|
|
}
|
|
|
|
return {
|
|
async handle(rawBody, headers) {
|
|
if (Buffer.byteLength(rawBody) > BODY_LIMIT) return { status: 413, body: { error: "body_too_large" } };
|
|
let envelope;
|
|
try { envelope = verifier.verify(rawBody, headers); }
|
|
catch { return { status: 401, body: { error: "invalid_signature" } }; }
|
|
if (!validateEnvelope(envelope)) return { status: 400, body: { error: "invalid_envelope" } };
|
|
if (closed || now() - createdAt >= TTL_MS) return { status: 410, body: { error: "proof_expired" } };
|
|
// Only synthetic content exists here. First signed delivery binds this
|
|
// single-run harness; a production connector must use server-owned sessions.
|
|
if (sessionId && sessionId !== envelope.session_id) return { status: 403, body: { error: "different_session" } };
|
|
const fingerprint = digest(rawBody);
|
|
const prior = receipts.get(envelope.idempotency_key);
|
|
if (prior) return prior.fingerprint === fingerprint ? prior.response : { status: 409, body: { error: "conflicting_retry" } };
|
|
if (receipts.size >= MAX_CALLS) return { status: 429, body: { error: "proof_call_limit" } };
|
|
sessionId ??= envelope.session_id;
|
|
// Reserve before execute yields, so concurrent retries share one result.
|
|
const response = Promise.resolve().then(() => execute(envelope));
|
|
receipts.set(envelope.idempotency_key, { fingerprint, response });
|
|
return response;
|
|
},
|
|
close() { closed = true; },
|
|
evidence() {
|
|
return { mode: "synthetic_provider_probe", liveQualification: "not_established", acceptedRequests: requests, expectedSyntheticResult: result?.text ?? null, resultReturned: events.some((event) => event.kind === "result_returned_to_tool"), events: structuredClone(events) };
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createProofServer(proof) {
|
|
const server = createServer(async (req, res) => {
|
|
const reply = (status, body) => {
|
|
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
res.end(JSON.stringify(body));
|
|
};
|
|
if (req.method === "GET" && req.url === "/health") return reply(200, { mode: "synthetic_provider_probe" });
|
|
if (req.method !== "POST" || req.url !== TOOL_PATH) return reply(404, { error: "not_found" });
|
|
if (req.headers["content-type"]?.split(";")[0].trim() !== "application/json") return reply(415, { error: "json_required" });
|
|
if (req.headers["content-encoding"] && req.headers["content-encoding"] !== "identity") return reply(415, { error: "encoding_not_supported" });
|
|
const chunks = [];
|
|
let length = 0;
|
|
try {
|
|
for await (const chunk of req) {
|
|
length += chunk.length;
|
|
if (length > BODY_LIMIT) { reply(413, { error: "body_too_large" }); return; }
|
|
chunks.push(chunk);
|
|
}
|
|
const result = await proof.handle(Buffer.concat(chunks).toString("utf8"), req.headers);
|
|
reply(result.status, result.body);
|
|
} catch {
|
|
if (!res.headersSent) reply(500, { error: "proof_failed" });
|
|
}
|
|
});
|
|
server.requestTimeout = 5_000;
|
|
server.headersTimeout = 5_000;
|
|
server.maxConnections = 16;
|
|
return server;
|
|
}
|