From 136c20d330d30dc8b9eae31f9c35b7b6faf79df1 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Sun, 8 Nov 2026 21:54:16 +0700 Subject: [PATCH] feat: add hosted credential request handoff --- docs/http-contract.md | 25 +++ packages/dewee-webchat/README.md | 10 ++ .../dewee-webchat/src/credential-request.ts | 49 ++++++ packages/dewee-webchat/src/protocol.ts | 4 + .../dewee-webchat/src/server/ccp-client.ts | 4 +- .../dewee-webchat/src/server/ccp-transport.ts | 38 ++++- .../src/server/gateway-client.ts | 34 +++- packages/dewee-webchat/src/server/handler.ts | 67 +++++++- .../dewee-webchat/src/widget/api-client.ts | 37 +++- packages/dewee-webchat/src/widget/element.ts | 155 ++++++++++++++++- packages/dewee-webchat/src/widget/i18n.ts | 3 + packages/dewee-webchat/src/widget/styles.ts | 4 + .../test/ccp-credential-request.test.ts | 96 +++++++++++ .../test/credential-request.test.ts | 26 +++ .../test/helpers/fake-ccp-ingress.ts | 12 +- .../legacy-gateway-characterization.test.ts | 19 +++ .../test/php-identity-ccp.test.ts | 56 ++++++- .../test/php-identity-gateway.test.ts | 21 +++ .../test/widget-credential-request.test.ts | 83 +++++++++ php/README.md | 6 + php/dewee-webchat.php | 158 ++++++++++++++++-- 21 files changed, 861 insertions(+), 46 deletions(-) create mode 100644 packages/dewee-webchat/src/credential-request.ts create mode 100644 packages/dewee-webchat/test/ccp-credential-request.test.ts create mode 100644 packages/dewee-webchat/test/credential-request.test.ts create mode 100644 packages/dewee-webchat/test/widget-credential-request.test.ts diff --git a/docs/http-contract.md b/docs/http-contract.md index 742d4d9..b06217e 100644 --- a/docs/http-contract.md +++ b/docs/http-contract.md @@ -94,10 +94,14 @@ Each SSE frame is `data: \n\n`; `: ping` comments keep the connection aliv | `delta` | `text` (answer delta) | | `tool_call` | `id`, `name`, `arguments` | | `tool_result` | `id`, `name`, `isError`, `result` | +| `credential_request` | `credentialRequest` (CCP signed-in hosted flow only) | | `done` | `content` (full answer), `usage?` | | `error` | `message`, `code?`, `remediation?`, `retryAfterMs?` | Every run ends with `done` or `error`, after which the server closes the stream. +Direct Gateway transport omits `request_credentials` tool calls and their results, and omits +unmatched or malformed tool results, so credential handoffs cannot appear as tool output. CCP +continues to expose tool names and statuses only. ### Admission errors @@ -339,6 +343,27 @@ Headers: `Authorization: Bearer `. Returns the visitor's own conv ] } ``` +## Hosted credential requests + +CCP transport with identity and the backend's explicit `credentialRequests.formOrigin` opt-in can +forward the Gateway's `credential_request` SSE event as `{type: "credential_request", credentialRequest}`. +History adds `credentialRequests` when the verified visitor has requests. Each card contains only +the value-free `webchat-credential-request.v1` metadata; the widget does not persist it. + +`POST {base}/credential-request`, `/credential-request-handoff` and `/credential-request-cancel` +accept `{agent, conversationId, credentialRequestId}` and a verified visitor bearer. They require an +HTTPS Origin and reauthenticate the current host user. Status and cancel return `{credentialRequest}`; +handoff returns `{credential_request_id, url, expires_at}` with `Cache-Control: no-store`. The URL +must use the configured HTTPS origin and the Dewee hosted-form route. It contains a short-lived token; +do not log, cache, publish, or add it to chat messages. The hosted form independently requires a +workspace approval administrator. + +After completion, `POST /chat` with an empty `message` and `credentialRequestId` resumes the bound +conversation. Gateway enforces one continuation; `credential_request_not_ready` and +`credential_request_resumed` are 409, `credential_request_not_found` is 404, and +`credential_requests_unavailable` is 403. Anonymous sessions and unconfigured backends cannot use +these routes. The request and SSE responses use `no-store`. + ## Extra request headers Hosts may configure the widget to add headers (for example `x-csrf-token`) to every request, and to use a diff --git a/packages/dewee-webchat/README.md b/packages/dewee-webchat/README.md index 04b26d4..28ba9d2 100644 --- a/packages/dewee-webchat/README.md +++ b/packages/dewee-webchat/README.md @@ -368,6 +368,16 @@ used), `allowInsecureHttp` does not apply, a user switch under `rebindEachReques previous user's session at CCP, and logout revokes it through [`handler.revoke`](#signed-in-users). The integration's sign-in mode must allow signed-in users. +### Hosted credential requests + +Credential requests are off by default and require CCP transport, signed-in identity, and Gateway/CCP +support enabled for the agent. To opt in, add `credentialRequests: { formOrigin: "https://app.example.com" }` +to the server handler options, using the exact HTTPS origin of your Dewee-hosted form. Use the ASCII +punycode form for an internationalized domain. The form is +opened only after the visitor chooses it; a workspace approval administrator must authenticate there. +The widget never collects the credential value. Anonymous sessions, direct Gateway transport, and +an untrusted form origin cannot use this flow. See the [HTTP contract](../../docs/http-contract.md#hosted-credential-requests). + ```ts import { createDeweeHandler, optionsFromEnv } from "dewee-webchat/server"; import { deweeIdentitySdk } from "dewee-webchat/server/identity"; diff --git a/packages/dewee-webchat/src/credential-request.ts b/packages/dewee-webchat/src/credential-request.ts new file mode 100644 index 0000000..a883bed --- /dev/null +++ b/packages/dewee-webchat/src/credential-request.ts @@ -0,0 +1,49 @@ +/** A value-free, server-authorized credential request. Never accept form values here. */ +export interface CredentialRequestView { + id: string; + schema_version: "webchat-credential-request.v1"; + status: "pending" | "applying" | "resolved" | "cancelled" | "expired" | "failed"; + title: string; + purpose: string; + target: { type: string; mode: string }; + fields: Array<{ key: string; label: string; kind: string; required: boolean }>; + expires_at: string; + agent_key: string; + completion: "hosted"; + resumable: boolean; +} + +export interface CredentialHandoff { credential_request_id: string; url: string; expires_at: string } +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const statuses = new Set(["pending", "applying", "resolved", "cancelled", "expired", "failed"]); +const record = (v: unknown): Record | null => v && typeof v === "object" && !Array.isArray(v) ? v as Record : null; +const text = (v: unknown, max: number): v is string => typeof v === "string" && v.length <= max; +const date = (v: unknown): v is string => text(v, 64) && Number.isFinite(Date.parse(v)); + +/** Rebuild known fields, reject newer or malformed cards and never forward arbitrary properties. */ +export function parseCredentialRequest(raw: unknown, expectedAgent: string): CredentialRequestView | null { + const v = record(raw); const target = record(v?.target); + if (!v || !target || !text(v.id, 36) || !uuid.test(v.id) || v.schema_version !== "webchat-credential-request.v1" || + typeof v.status !== "string" || !statuses.has(v.status) || !text(v.title, 300) || !text(v.purpose, 4000) || + !text(target.type, 100) || !text(target.mode, 100) || !Array.isArray(v.fields) || v.fields.length > 32 || + !date(v.expires_at) || v.agent_key !== expectedAgent || v.completion !== "hosted" || typeof v.resumable !== "boolean") return null; + const fields: CredentialRequestView["fields"] = []; + for (const item of v.fields) { + const f = record(item); + if (!f || !text(f.key, 100) || !/^[A-Za-z0-9_.-]+$/.test(f.key) || !text(f.label, 300) || !text(f.kind, 100) || typeof f.required !== "boolean") return null; + fields.push({ key: f.key, label: f.label, kind: f.kind, required: f.required }); + } + return { id: v.id, schema_version: "webchat-credential-request.v1", status: v.status as CredentialRequestView["status"], title: v.title, purpose: v.purpose, + target: { type: target.type, mode: target.mode }, fields, expires_at: v.expires_at, agent_key: expectedAgent, completion: "hosted", resumable: v.resumable }; +} + +/** The one-time link is accepted only at a host configured by the integrator. */ +export function parseCredentialHandoff(raw: unknown, expectedId: string, trustedOrigin: string): CredentialHandoff | null { + const v = record(raw); + if (!v || v.credential_request_id !== expectedId || !text(v.url, 4096) || !date(v.expires_at) || Date.parse(v.expires_at) <= Date.now()) return null; + try { + const url = new URL(v.url); + if (url.protocol !== "https:" || url.origin !== trustedOrigin || url.username || url.password || url.hash || url.pathname !== "/credential-requests/open" || !/^\?token=[A-Za-z0-9_-]{43}$/.test(url.search)) return null; + return { credential_request_id: expectedId, url: url.href, expires_at: v.expires_at }; + } catch { return null; } +} diff --git a/packages/dewee-webchat/src/protocol.ts b/packages/dewee-webchat/src/protocol.ts index 8fedf8d..3a9ebc7 100644 --- a/packages/dewee-webchat/src/protocol.ts +++ b/packages/dewee-webchat/src/protocol.ts @@ -8,6 +8,7 @@ export type ChatStreamEvent = | { type: "delta"; text: string } | { type: "tool_call"; id: string; name: string; arguments?: unknown } | { type: "tool_result"; id: string; name: string; isError: boolean; result?: string } + | { type: "credential_request"; credentialRequest: import("./credential-request").CredentialRequestView } | { type: "done"; content: string; usage?: Record } | { type: "error"; message: string; code?: string; remediation?: Remediation; retryAfterMs?: number }; @@ -75,6 +76,8 @@ export interface PublicConfig { pageContextVersions?: number[]; /** Present (true) when the backend signs users in through Dewee: the widget then asks `/session` on every page load. */ identity?: boolean; + /** Trusted origin of the hosted credential form, present only when explicitly enabled. */ + credentialFormOrigin?: string; /** * Present (true) when a returning visitor's `/session` refresh (a valid anonymous `token` in the * body) needs no Turnstile token (ccp transport); only new visitors are challenged then. With @@ -181,6 +184,7 @@ export interface ChatRequestBody { pageContext?: unknown; /** Ephemeral, untrusted content for this turn; distinct from the legacy page-action context. */ page_context?: import("./widget/page-context").PageContextAttachment; + credentialRequestId?: string; } export const HEADER_GATEWAY_URL = "x-dewee-gateway-url"; diff --git a/packages/dewee-webchat/src/server/ccp-client.ts b/packages/dewee-webchat/src/server/ccp-client.ts index 801c766..efff283 100644 --- a/packages/dewee-webchat/src/server/ccp-client.ts +++ b/packages/dewee-webchat/src/server/ccp-client.ts @@ -7,7 +7,7 @@ // another origin. Nothing a visitor sends ever becomes part of a URL. /** Routes of the public ingress this client calls. Fixed names: callers cannot inject path segments. */ -export type CcpRoute = "config" | "session" | "chat" | "history" | "abort" | "identity/exchange" | "identity/revoke" | "link" | "job-access"; +export type CcpRoute = "config" | "session" | "chat" | "history" | "abort" | "identity/exchange" | "identity/revoke" | "link" | "job-access" | "credential-request" | "credential-request-handoff" | "credential-request-cancel"; /** Validated CCP endpoint options (see {@link parseCcpOptions}). */ export interface CcpOptions { @@ -133,6 +133,7 @@ export interface CcpRequest { idempotencyKey?: string; accept?: string; signal?: AbortSignal; + credentialRequests?: boolean; } /** Thin client bound to one validated ingress base. */ @@ -161,6 +162,7 @@ export class CcpClient { if (req.visitorIp && VISITOR_IP_RE.test(req.visitorIp)) headers["x-dewee-visitor-ip"] = req.visitorIp; if (req.requestId) headers["x-request-id"] = req.requestId; if (req.idempotencyKey) headers["idempotency-key"] = req.idempotencyKey; + if (req.credentialRequests) headers["x-dewee-webchat-features"] = "credential-requests"; let res: Response; try { // "manual" instead of "error": Cloudflare Workers reject redirect "error"; a 3xx (or an opaque diff --git a/packages/dewee-webchat/src/server/ccp-transport.ts b/packages/dewee-webchat/src/server/ccp-transport.ts index 98a5aea..1e695ec 100644 --- a/packages/dewee-webchat/src/server/ccp-transport.ts +++ b/packages/dewee-webchat/src/server/ccp-transport.ts @@ -7,6 +7,7 @@ // shown, quotas, and every authorization of the visitor. Nothing CCP says is passed to a browser // verbatim: events and errors are rebuilt from known fields with this module's own messages. import { stripPageBlock } from "../actions/page-block"; +import { parseCredentialRequest, type CredentialRequestView } from "../credential-request"; import { parseJobAccessLaunch, type JobAccessLaunch } from "../job-access"; import { isAdmissionErrorCode, isIdentityErrorCode, type AdmissionErrorCode, type AgentInfo, type ChatStreamEvent, type HistoryMessage, type IdentityErrorCode, type LinkResponse, type Remediation, type ToolCallView } from "../protocol"; import { CcpClient, CcpError, knownRemediation, readSseJson, type CcpRequest, type CcpRoute } from "./ccp-client"; @@ -113,6 +114,13 @@ export function browserErrorFor(e: unknown, route: CcpRoute): BrowserError { if (e.code === "rate_limited" || e.code === "concurrency_limited" || e.status === 429) return { status: 429, code: "RATE_LIMITED", message: "", admission: "RATE_LIMITED", retryAfterMs }; } switch (e.code) { + case "credential_requests_unavailable": + return { status: 403, code: e.code, message: "Credential requests are unavailable" }; + case "credential_request_not_found": + return { status: 404, code: e.code, message: "Credential request not found" }; + case "credential_request_not_ready": + case "credential_request_resumed": + return { status: 409, code: e.code, message: "Credential request cannot be resumed" }; case "page_context_invalid": return { status: 400, code: e.code, message: "Invalid page context" }; case "page_context_unsupported": @@ -231,7 +239,7 @@ export interface CcpExchangeResult { export type CcpTransport = ReturnType; -export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps) { +export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps, credentialRequests = false) { let config: { value: CcpConfig; at: number } | undefined; let pendingConfig: Promise | undefined; const edges = new Map(); @@ -330,7 +338,7 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps ctx: VisitorContext, visitorId: string, sealed: EdgeSession | undefined, - input: { message: string; conversationId: string; agent?: string; identitySession?: string; pageContext?: unknown; signal: AbortSignal }, + input: { message: string; conversationId: string; agent?: string; identitySession?: string; pageContext?: unknown; credentialRequestId?: string; signal: AbortSignal }, ): Promise { const body = { message: input.message, @@ -338,8 +346,9 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps ...withAgent(input.agent), ...(input.identitySession ? { identity_session: input.identitySession } : {}), ...(input.pageContext !== undefined ? { page_context: input.pageContext } : {}), + ...(input.credentialRequestId ? { credential_request_id: input.credentialRequestId } : {}), }; - return withEdge(ctx, visitorId, sealed, (edge) => client.send("chat", { ...base(ctx), body, session: edge.token, accept: "text/event-stream", signal: input.signal })); + return withEdge(ctx, visitorId, sealed, (edge) => client.send("chat", { ...base(ctx), body, session: edge.token, accept: "text/event-stream", signal: input.signal, credentialRequests: credentialRequests && !!input.identitySession })); }, /** @@ -347,7 +356,7 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps * (`done` or `error`) last. Reasoning and tool frames pass only when the integration shows them, * tool arguments and results never; unknown frames are dropped. */ - async *chatEvents(res: Response, cfg: CcpConfig, conversationId: string, requestedAgent: string): AsyncGenerator { + async *chatEvents(res: Response, cfg: CcpConfig, conversationId: string, requestedAgent: string, verified = false): AsyncGenerator { let started = false; const start = (f?: any): ChatStreamEvent => { started = true; @@ -380,6 +389,11 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps case "tool_result": if (cfg.showToolActivity && typeof f.id === "string" && typeof f.name === "string") yield { type: "tool_result", id: f.id.slice(0, 128), name: f.name.slice(0, 128), isError: f.isError === true }; break; + case "credential_request": { + const view = credentialRequests && verified ? parseCredentialRequest(f.credential_request, requestedAgent) : null; + if (view) yield { type: "credential_request", credentialRequest: view }; + break; + } case "done": { const usage = numericUsage(f.usage); yield { type: "done", content: typeof f.content === "string" ? f.content : "", ...(usage ? { usage } : {}) }; @@ -408,7 +422,7 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps }, /** The visitor's own conversation, folded for the widget. An unknown or foreign conversation is empty. */ - async history(ctx: VisitorContext, visitorId: string, sealed: EdgeSession | undefined, input: { conversationId: string; agent?: string; identitySession?: string }): Promise { + async history(ctx: VisitorContext, visitorId: string, sealed: EdgeSession | undefined, input: { conversationId: string; agent?: string; identitySession?: string }): Promise<{ messages: HistoryMessage[]; credentialRequests?: CredentialRequestView[] }> { const cfg = await getConfig(); const body = { conversation_id: input.conversationId, @@ -418,9 +432,9 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps }; let data: Record; try { - data = await withEdge(ctx, visitorId, sealed, (edge) => client.json("history", { ...base(ctx), body, session: edge.token })); + data = await withEdge(ctx, visitorId, sealed, (edge) => client.json("history", { ...base(ctx), body, session: edge.token, credentialRequests: credentialRequests && !!input.identitySession })); } catch (e) { - if (e instanceof CcpError && e.code === "not_found") return []; + if (e instanceof CcpError && e.code === "not_found") return { messages: [] }; throw e; } const out: HistoryMessage[] = []; @@ -442,7 +456,15 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps if (!msg.content && !msg.thinking && !msg.toolCalls) continue; out.push(msg); } - return out; + const cards = credentialRequests && input.identitySession && Array.isArray(data.credential_requests) + ? data.credential_requests.slice(0, 100).map((view: unknown) => parseCredentialRequest(view, input.agent || cfg.defaultAgent)).filter((view: CredentialRequestView | null): view is CredentialRequestView => view !== null) + : []; + return { messages: out, ...(cards.length ? { credentialRequests: cards } : {}) }; + }, + + async credentialRequest(ctx: VisitorContext, visitorId: string, sealed: EdgeSession | undefined, route: "credential-request" | "credential-request-handoff" | "credential-request-cancel", input: { conversationId: string; requestId: string; agent: string; identitySession: string }): Promise { + return withEdge(ctx, visitorId, sealed, (edge) => client.json(route, { ...base(ctx), session: edge.token, credentialRequests: true, + body: { conversation_id: input.conversationId, credential_request_id: input.requestId, identity_session: input.identitySession, agent: input.agent } })); }, /** diff --git a/packages/dewee-webchat/src/server/gateway-client.ts b/packages/dewee-webchat/src/server/gateway-client.ts index c021d1d..97567ef 100644 --- a/packages/dewee-webchat/src/server/gateway-client.ts +++ b/packages/dewee-webchat/src/server/gateway-client.ts @@ -168,6 +168,11 @@ export function buildSessionKey(agent: string, conversationId: string): string { return `agent:${agent}:ws:direct:${conversationId}`; } +/** Credential requests are not ordinary tool output: their arguments/results may contain a handoff. */ +function credentialTool(name: unknown): boolean { + return typeof name === "string" && /(?:^|__)request_credentials$/i.test(name); +} + export interface StreamChatOptions { creds: GatewayCredentials; /** Gateway user id (anonymous visitors). Ignored when `identitySession` or `connection` is set. */ @@ -196,6 +201,8 @@ export async function* streamChat(opts: StreamChatOptions): AsyncGenerator void) | null = null; let finished = false; + const toolNames = new Map(); + const sensitiveToolIds = new Set(); const push = (e: ChatStreamEvent) => { queue.push(e); wake?.(); }; const end = () => { finished = true; wake?.(); }; @@ -208,8 +215,21 @@ export async function* streamChat(opts: StreamChatOptions): AsyncGenerator(); + for (const m of raw) for (const tc of Array.isArray(m?.tool_calls) ? m.tool_calls : []) { + if (credentialTool(tc?.name ?? tc?.function?.name) && typeof tc?.id === "string") sensitiveIds.add(tc.id); + } let current: HistoryMessage | null = null; for (const m of raw) { if (m.role === "user") { @@ -278,12 +302,14 @@ export function foldHistory(raw: any[]): HistoryMessage[] { if (m.thinking) current.thinking = (current.thinking ? current.thinking + "\n\n" : "") + m.thinking; if (m.content) current.content = current.content ? `${current.content}\n\n${m.content}` : String(m.content); for (const tc of m.tool_calls ?? []) { - const view: ToolCallView = { id: tc.id, name: tc.name ?? tc.function?.name ?? "tool", arguments: tc.arguments ?? tc.function?.arguments, status: "done" }; + const name = tc.name ?? tc.function?.name; + if (typeof tc.id !== "string" || !tc.id || !name || sensitiveIds.has(tc.id) || credentialTool(name)) continue; + const view: ToolCallView = { id: tc.id, name, arguments: tc.arguments ?? tc.function?.arguments, status: "done" }; (current.toolCalls ??= []).push(view); } } else if (m.role === "tool" && current?.toolCalls) { const tc = current.toolCalls.find((t) => t.id === m.tool_call_id); - if (tc) { + if (typeof m.tool_call_id === "string" && m.tool_call_id && tc && !sensitiveIds.has(m.tool_call_id)) { tc.result = clip(m.content); if (m.is_error) { tc.isError = true; tc.status = "error"; } } diff --git a/packages/dewee-webchat/src/server/handler.ts b/packages/dewee-webchat/src/server/handler.ts index 8026819..1eb9ae7 100644 --- a/packages/dewee-webchat/src/server/handler.ts +++ b/packages/dewee-webchat/src/server/handler.ts @@ -51,6 +51,7 @@ import { CcpClient, CcpError, parseCcpOptions, type CcpOptions } from "./ccp-cli import { resolveTransport } from "./transport-selection"; import { readChatBody, validPageContext } from "./page-context"; import { JobAccessRefusal, mintJobAccess } from "./job-access"; +import { parseCredentialHandoff, parseCredentialRequest } from "../credential-request"; import { CCP_AGENT_RE, CCP_ID_RE, EDGE_RESEAL_MS, browserErrorFor, createCcpTransport, type BrowserError, type CcpConfig, type EdgeSession, type VisitorContext } from "./ccp-transport"; import { memoryIdentitySessionStore, type IdentitySessionStore, type StoredIdentitySession } from "./identity-session-store"; import { allowedImageSource, fetchRasterImage, issueImageTicket, readImageTicketBody, verifyImageTicket } from "./image-proxy"; @@ -209,6 +210,8 @@ export interface DeweeHandlerOptions { allowedHeaders?: string[]; /** HTTPS directory prefixes from which the backend may proxy images; disabled when unset. */ imageSources?: string[]; + /** Enable signed-in hosted credential requests, pinning the Dewee form's HTTPS origin. */ + credentialRequests?: { formOrigin: string }; /** Explicitly public directories in this Worker's Assets binding. Bypasses site middleware. */ cloudflarePublicImageAssets?: { binding: { fetch(request: Request): Promise }; prefixes: string[] }; /** @@ -320,6 +323,16 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { ...options, }; const transport = resolveTransport(o); + let credentialFormOrigin: string | undefined; + if (o.credentialRequests) { + if (transport !== "ccp" || !o.identity) throw new Error("[dewee-webchat] credentialRequests requires CCP transport and identity"); + try { + if (typeof o.credentialRequests.formOrigin !== "string" || /[^\x21-\x7e]|[\\%]/.test(o.credentialRequests.formOrigin)) throw new Error(); + const u = new URL(o.credentialRequests.formOrigin); + if (u.protocol !== "https:" || u.username || u.password || u.pathname !== "/" || u.search || u.hash) throw new Error(); + credentialFormOrigin = u.origin; + } catch { throw new Error("[dewee-webchat] credentialRequests.formOrigin must be an ASCII HTTPS origin (use punycode for internationalized domains)"); } + } if (o.imageSources !== undefined && !Array.isArray(o.imageSources)) throw new Error("[dewee-webchat] imageSources must be an array"); const imageSources = (o.imageSources ?? []).map((origin) => { let url: URL; @@ -361,6 +374,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { ? createCcpTransport( new CcpClient(parseCcpOptions(o.ccp)), idn ? { createAssertion: (a) => idn.sdk.createAssertion!(a), signer: () => identitySigner(), ttlSeconds: idn.sessionTtlSeconds } : undefined, + !!credentialFormOrigin, ) : null; @@ -845,6 +859,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { ...(o.turnstile ? { refreshWithoutTurnstile: true } : {}), maxMessageLength: Math.min(o.maxMessageLength, cfg.maxMessageChars), ...(cfg.pageContextVersions.length ? { pageContextVersions: cfg.pageContextVersions } : {}), + ...(credentialFormOrigin ? { credentialFormOrigin } : {}), version: VERSION, }; if (idn) { @@ -975,7 +990,14 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { if (!input.body || typeof input.body !== "object" || Array.isArray(input.body)) return fail(req, 400, "Invalid request", "bad_request"); const body: any = input.body; const message = typeof body?.message === "string" ? body.message.trim() : ""; - if (!message) return fail(req, 400, "Message is required"); + const resumeId = body?.credentialRequestId; + if (resumeId !== undefined) { + if (!ccp || !credentialFormOrigin || !claims.ids || typeof resumeId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(resumeId) || (body.message !== undefined && typeof body.message !== "string") || message || body?.actions != null || body?.pageContext != null || body?.page_context != null || typeof body?.conversationId !== "string" || !CCP_ID_RE.test(body.conversationId)) return fail(req, 400, "Invalid request", "bad_request"); + try { const origin = req.headers.get("origin"); const u = new URL(origin ?? ""); if (u.protocol !== "https:" || u.origin !== origin) throw new Error(); } + catch { return fail(req, 403, "Origin not allowed", "forbidden_origin"); } + const rebound = await rebindRefusal(req, claims, true); + if (rebound) return rebound; + } else if (!message) return fail(req, 400, "Message is required"); if (message.length > o.maxMessageLength) return fail(req, 413, `Message exceeds ${o.maxMessageLength} characters`, "too_long"); // CCP accepts 8 to 64 characters; a shorter id the browser chose counts as invalid and is replaced. const idRe = ccp ? CCP_ID_RE : ID_RE; @@ -1003,7 +1025,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { if (typeof body.page_context === "object" && Number.isInteger(body.page_context.version) && body.page_context.version !== 1) return fail(req, 400, "Page context is not supported", "page_context_unsupported"); if (!validPageContext(body.page_context)) return fail(req, 400, "Invalid page context", "page_context_invalid"); } - if (ccp) return ccpChat(req, claims, { message: pageBlock + message, rawMessage: message, conversationId, agent, actionsHash, pageContext: body?.page_context }); + if (ccp) return ccpChat(req, claims, { message: pageBlock + message, rawMessage: message, conversationId, agent, actionsHash, pageContext: body?.page_context, credentialRequestId: resumeId }); if (!creds) return fail(req, 500, "Gateway is not configured", "not_configured"); // narrowing only: checked above // A signed-in user's session is checked before streaming starts, so an expired or revoked @@ -1088,7 +1110,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { async function ccpChat( req: Request, claims: VisitorClaims, - turn: { message: string; rawMessage: string; conversationId: string; agent: string; actionsHash?: string; pageContext?: unknown }, + turn: { message: string; rawMessage: string; conversationId: string; agent: string; actionsHash?: string; pageContext?: unknown; credentialRequestId?: string }, ): Promise { const ctx = visitorContext(req); const visitorId = claims.v; @@ -1127,6 +1149,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { agent: turn.agent || undefined, identitySession: claims.ids, pageContext: turn.pageContext ?? undefined, + credentialRequestId: turn.credentialRequestId, signal: abort.signal, }); } catch (e) { @@ -1163,7 +1186,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { let errMsg: string | undefined; let agent = turn.agent || cfg.defaultAgent; try { - for await (const ev of ccp!.chatEvents(upstream, cfg, turn.conversationId, agent)) { + for await (const ev of ccp!.chatEvents(upstream, cfg, turn.conversationId, agent, !!claims.ids)) { if (ev.type === "start") { runId = ev.runId; agent = ev.agent; @@ -1205,7 +1228,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { return new Response(readable, { headers: { "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-cache, no-transform", + "cache-control": "no-store, no-transform", "x-accel-buffering": "no", ...corsHeaders(req), }, @@ -1222,8 +1245,8 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { // A browser-valid id CCP cannot hold has no conversation yet. if (!CCP_ID_RE.test(conversationId)) return json(req, { messages: [] }); try { - const messages = await ccp!.history(visitorContext(req), claims.v, sealedEdge(claims), { conversationId, agent: agent || undefined, identitySession: claims.ids }); - return json(req, { messages }); + const history = await ccp!.history(visitorContext(req), claims.v, sealedEdge(claims), { conversationId, agent: agent || undefined, identitySession: claims.ids }); + return json(req, history); } catch (e) { return ccpFail(req, e, "history", claims.ids); } @@ -1285,6 +1308,35 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { } } + /** Credential handoffs are bound to an actively authenticated user and a fixed Dewee origin. */ + async function handleCredentialRequest(req: Request, route: "credential-request" | "credential-request-handoff" | "credential-request-cancel"): Promise { + if (!ccp || !credentialFormOrigin || !idn) return fail(req, 404, "Not found", "not_found"); + const origin = req.headers.get("origin"); + try { const u = new URL(origin ?? ""); if (u.protocol !== "https:" || u.origin !== origin) throw new Error(); } + catch { return fail(req, 403, "Origin not allowed", "forbidden_origin"); } + const claims = await visitorFrom(req); + if (!claims?.ids || !claims.ida) return identityFail(req, "IDENTITY_REQUIRED"); + const refused = await rebindRefusal(req, claims, true); + if (refused) return refused; + if (limiter && !(await limiter(`credential:${claims.v}`, route === "credential-request-handoff" ? "link" : "preauth"))) return fail(req, 429, "Too many requests", "rate_limited"); + const raw = await readImageTicketBody(req); + let body: any; + try { body = raw && JSON.parse(raw); } catch { return fail(req, 400, "Invalid request", "bad_request"); } + if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some((key) => !["conversationId", "credentialRequestId", "agent"].includes(key)) || typeof body.conversationId !== "string" || !CCP_ID_RE.test(body.conversationId) || typeof body.credentialRequestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(body.credentialRequestId)) return fail(req, 400, "Invalid request", "bad_request"); + const agent = ccpAgentFor(req, claims, body.agent); + if (agent instanceof Response) return agent; + if (!agent) return fail(req, 403, "Agent not allowed", "agent_not_allowed"); + try { + const result = await ccp.credentialRequest(visitorContext(req), claims.v, sealedEdge(claims), route, { conversationId: body.conversationId, requestId: body.credentialRequestId, agent, identitySession: claims.ids }); + if (route === "credential-request-handoff") { + const handoff = parseCredentialHandoff(result, body.credentialRequestId, credentialFormOrigin); + return handoff ? json(req, handoff) : fail(req, 502, "Credential request is unavailable", "credential_request_unavailable"); + } + const view = parseCredentialRequest((result as { credential_request?: unknown } | null)?.credential_request, agent); + return view && view.id === body.credentialRequestId ? json(req, { credentialRequest: view }) : fail(req, 502, "Credential request is unavailable", "credential_request_unavailable"); + } catch (e) { return ccpFail(req, e, route, claims.ids); } + } + /** Web-first linking: starts a Dewee link for the signed-in user and returns the proof code. */ async function handleLink(req: Request): Promise { const claims = await visitorFrom(req); @@ -1400,6 +1452,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { if (req.method === "GET" && action === "history") return await handleHistory(req); if (req.method === "POST" && action === "link" && idn) return await handleLink(req); if (req.method === "POST" && action === "job-access" && idn) return await handleJobAccess(req); + if (req.method === "POST" && (action === "credential-request" || action === "credential-request-handoff" || action === "credential-request-cancel")) return await handleCredentialRequest(req, action); return fail(req, 404, "Not found", "not_found"); } catch (e) { if (e instanceof IdentityStoreError) return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" }); diff --git a/packages/dewee-webchat/src/widget/api-client.ts b/packages/dewee-webchat/src/widget/api-client.ts index 2fa829d..23c5c2b 100644 --- a/packages/dewee-webchat/src/widget/api-client.ts +++ b/packages/dewee-webchat/src/widget/api-client.ts @@ -1,5 +1,6 @@ // Browser client for the dewee-webchat backend HTTP contract. import { parseJobAccessLaunch, type JobAccessLaunch } from "../job-access"; +import { parseCredentialHandoff, parseCredentialRequest, type CredentialHandoff, type CredentialRequestView } from "../credential-request"; import { HEADER_GATEWAY_TOKEN, HEADER_GATEWAY_URL, @@ -345,10 +346,37 @@ export class ApiClient { } async history(agent: string, conversationId: string): Promise { + return (await this.historyDetails(agent, conversationId)).messages; + } + + async historyDetails(agent: string, conversationId: string): Promise<{ messages: HistoryMessage[]; credentialRequests: CredentialRequestView[] }> { const res = await this.signedInRequest(async () => this.request(this.url("history", { agent, conversationId }), { headers: await this.headers(true) }), ); - return (await this.parse<{ messages: HistoryMessage[] }>(res)).messages; + const data = await this.parse<{ messages: HistoryMessage[]; credentialRequests?: unknown[] }>(res); + const cards = this.session?.verified && this.config?.credentialFormOrigin && Array.isArray(data.credentialRequests) + ? data.credentialRequests.slice(0, 100).map((v) => parseCredentialRequest(v, agent)).filter((v): v is CredentialRequestView => v !== null) : []; + return { messages: data.messages, credentialRequests: cards }; + } + + /** Never retry a credential action under a newly authenticated identity. */ + async credentialRequest(route: "credential-request" | "credential-request-handoff" | "credential-request-cancel", agent: string, conversationId: string, credentialRequestId: string, signal?: AbortSignal): Promise { + const session = await this.ensureSession(); + const origin = (await this.getConfig()).credentialFormOrigin; + if (!session.verified || !origin) throw new ApiError("Credential request is unavailable", 403, "credential_request_unavailable"); + try { + const headers = await this.headers(true); + if (this.session !== session) throw new Error("identity changed"); + const response = await this.request(this.url(route), { method: "POST", headers, body: JSON.stringify({ agent, conversationId, credentialRequestId }), redirect: "error", signal }); + const data = await this.parse<{ credentialRequest?: unknown }>(response); + if (this.session !== session) throw new Error("identity changed"); + const result = route === "credential-request-handoff" ? parseCredentialHandoff(data, credentialRequestId, origin) : parseCredentialRequest(data.credentialRequest, agent); + if (!result || ("id" in result && result.id !== credentialRequestId)) throw new Error("invalid credential request"); + return result; + } catch (error) { + const code = error instanceof ApiError && ["credential_requests_unavailable", "credential_request_not_found", "credential_request_not_ready", "credential_request_resumed"].includes(error.code ?? "") ? error.code : "credential_request_unavailable"; + throw new ApiError("Credential request is unavailable", error instanceof ApiError ? error.status : 0, code); + } } /** @@ -373,7 +401,7 @@ export class ApiClient { ): Promise { await this.ensureSession(); let res = await this.request(this.url("chat"), { method: "POST", headers: await this.headers(true), body: JSON.stringify(input), signal }); - if (res.status === 401) { + if (res.status === 401 && !input.credentialRequestId) { const before = this.visitorId(); this.forgetSession(); await this.ensureSession(); @@ -398,6 +426,11 @@ export class ApiClient { if (!data) continue; let event: ChatStreamEvent; try { event = JSON.parse(data) as ChatStreamEvent; } catch { continue; /* malformed frame */ } + if (event.type === "credential_request") { + const view = this.session?.verified && this.config?.credentialFormOrigin ? parseCredentialRequest(event.credentialRequest, input.agent) : null; + if (!view) continue; + event = { type: "credential_request", credentialRequest: view }; + } if (event.type === "error" && event.code && RENEW_CODES.has(event.code)) renew = true; try { onEvent(event); } catch { /* a failing listener never breaks the stream */ } } diff --git a/packages/dewee-webchat/src/widget/element.ts b/packages/dewee-webchat/src/widget/element.ts index 8946a26..f6114d4 100644 --- a/packages/dewee-webchat/src/widget/element.ts +++ b/packages/dewee-webchat/src/widget/element.ts @@ -21,9 +21,11 @@ import { disposeMarkdownImages, renderMarkdown } from "./markdown"; import { SendCooldown } from "./send-cooldown"; import { styles } from "./styles"; import type { PageContextAttachment } from "./page-context"; +import type { CredentialRequestView } from "../credential-request"; /** Pause after a RATE_LIMITED refusal that says nothing about when to retry. */ const DEFAULT_COOLDOWN_MS = 5_000; +const CREDENTIAL_GONE_CODES = new Set(["credential_requests_unavailable", "credential_request_not_found"]); export interface DeweeChatConfig { /** Backend base URL (the server handler). Default: "/api/dewee". */ @@ -202,6 +204,11 @@ export class DeweeChatElement extends ElementBase { private messages: ViewMessage[] = []; private running: AbortController | null = null; private jobAccessLaunch: AbortController | null = null; + private credentialCards = new Map(); + private credentialGeneration = 0; + private credentialBusy = new Set(); + private credentialPopups = new Map }>(); + private credentialRequestsInFlight = new Set(); private activated = false; private probedPage = false; private strings: Strings = pickStrings(); @@ -391,6 +398,7 @@ export class DeweeChatElement extends ElementBase { /** One request/response with the agent. */ private async runTurn(message: string, hidden: boolean, attachment?: PageContextAttachment): Promise { + const generation = this.credentialGeneration; this.pushMessage({ role: "user", content: message, hidden }); const reply: ViewMessage = { role: "assistant", content: "", status: "streaming", startedAt: Date.now() }; this.pushMessage(reply); @@ -399,7 +407,7 @@ export class DeweeChatElement extends ElementBase { try { // The session decides which backend conversation (visitor) the page-action ack belongs to. await this.client().ensureSession(); - await this.client().chat({ ...this.chatBody(message), ...(attachment ? { page_context: attachment } : {}) }, (e) => this.onStreamEvent(reply, e), this.running!.signal); + await this.client().chat({ ...this.chatBody(message), ...(attachment ? { page_context: attachment } : {}) }, (e) => { if (generation === this.credentialGeneration) this.onStreamEvent(reply, e); }, this.running!.signal); if (reply.status === "streaming") reply.status = "done"; } catch (e) { if ((e as Error).name === "AbortError") { @@ -538,6 +546,8 @@ export class DeweeChatElement extends ElementBase { */ async renewSession(): Promise { this.stop(); + this.clearCredentials(); + this.renderLog(); try { await this.client().renewSession(); } catch (e) { @@ -636,6 +646,7 @@ export class DeweeChatElement extends ElementBase { /** Starts a fresh conversation with the current agent. */ newConversation(): void { this.stop(); + this.clearCredentials(); this.conversationId = randomConversationId(); if (this.config.persist !== false) store.set(this.convKey(), this.conversationId); this.messages = []; @@ -655,6 +666,7 @@ export class DeweeChatElement extends ElementBase { } disconnectedCallback(): void { + this.clearCredentials(); this.pageGeneration++; this.sharedPage = undefined; this.els.pageChoice.checked = false; @@ -984,6 +996,8 @@ export class DeweeChatElement extends ElementBase { } private async loadConversation() { + this.clearCredentials(); + const generation = this.credentialGeneration; const persist = this.config.persist !== false; const saved = persist ? store.get(this.convKey()) : null; this.conversationId = saved || randomConversationId(); @@ -992,8 +1006,10 @@ export class DeweeChatElement extends ElementBase { this.renderLog(); if (!saved) return; try { - const history = await this.client().history(this.agentId, this.conversationId); - this.messages = decorateHistory(history.map((m) => ({ ...m, status: "done" as const }))); + const history = await this.client().historyDetails(this.agentId, this.conversationId); + if (generation !== this.credentialGeneration) return; + this.messages = decorateHistory(history.messages.map((m) => ({ ...m, status: "done" as const }))); + for (const card of history.credentialRequests) this.credentialCards.set(card.id, card); this.renderLog(); } catch { /* history is a nicety: a fresh conversation still works */ @@ -1006,9 +1022,138 @@ export class DeweeChatElement extends ElementBase { log.replaceChildren(); if (!this.messages.length) log.appendChild(this.renderWelcome()); for (const m of this.messages) log.appendChild(this.renderMessage(m)); + for (const card of this.credentialCards.values()) log.appendChild(this.renderCredential(card)); log.scrollTop = log.scrollHeight; } + private clearCredentials() { + this.credentialGeneration++; + for (const id of this.credentialPopups.keys()) this.closeCredentialPopup(id); + for (const request of this.credentialRequestsInFlight) request.abort(); + this.credentialRequestsInFlight.clear(); + this.credentialCards.clear(); + this.credentialBusy.clear(); + } + + private closeCredentialPopup(id: string) { + const active = this.credentialPopups.get(id); + if (!active) return; + this.credentialPopups.delete(id); + if (active.timer) clearTimeout(active.timer); + active.controller.abort(); + try { active.popup.close(); } catch { /* only this popup */ } + } + + private discardCredential(id: string) { + this.closeCredentialPopup(id); + this.credentialCards.delete(id); + } + + private credentialButton(card: CredentialRequestView, label: string, run: () => void, disabled = false): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn"; + button.textContent = label; + button.disabled = disabled || this.credentialBusy.has(card.id); + button.addEventListener("click", run); + return button; + } + + private renderCredential(card: CredentialRequestView): HTMLElement { + const root = document.createElement("section"); + root.className = "msg assistant credential-request"; + const bubble = document.createElement("div"); bubble.className = "bubble"; + const title = document.createElement("strong"); title.textContent = card.title; + const purpose = document.createElement("p"); purpose.textContent = card.purpose; + const intro = document.createElement("p"); intro.textContent = this.strings.credential.intro; + const destination = document.createElement("p"); destination.textContent = [card.target.type, card.target.mode].filter(Boolean).join(" · "); + const status = document.createElement("p"); status.setAttribute("role", "status"); + const effective = card.status === "pending" && Date.parse(card.expires_at) <= Date.now() ? "expired" : card.status; + status.textContent = this.strings.credential.status[effective]; + const list = document.createElement("ul"); + for (const field of card.fields) { const li = document.createElement("li"); li.textContent = `${field.label} (${field.required ? this.strings.credential.required : this.strings.credential.optional})`; list.appendChild(li); } + const expiry = document.createElement("p"); expiry.textContent = `${this.strings.credential.expires}: ${new Date(card.expires_at).toLocaleString()}`; + const actions = document.createElement("div"); actions.className = "credential-actions"; + if (effective === "pending") { + actions.append( + this.credentialButton(card, this.strings.credential.open, () => { void this.openCredential(card); }, this.credentialPopups.has(card.id)), + this.credentialButton(card, this.strings.credential.cancel, () => { void this.updateCredential(card, "credential-request-cancel"); }), + ); + } + if (card.resumable) actions.append(this.credentialButton(card, this.strings.credential.continue, () => { void this.resumeCredential(card); })); + bubble.append(title, purpose, destination, list, intro, expiry, status, actions); root.appendChild(bubble); + return root; + } + + private async updateCredential(card: CredentialRequestView, route: "credential-request" | "credential-request-cancel"): Promise { + if (!this.credentialCards.has(card.id) || this.credentialBusy.has(card.id)) return; + const generation = this.credentialGeneration, agent = this.agentId, conversation = this.conversationId; + const controller = new AbortController(); this.credentialRequestsInFlight.add(controller); + this.credentialBusy.add(card.id); this.renderLog(); + try { + const result = await this.client().credentialRequest(route, agent, conversation, card.id, controller.signal); + if (generation !== this.credentialGeneration || !("id" in result)) return; + this.credentialCards.set(result.id, result); + if (route === "credential-request-cancel") this.closeCredentialPopup(card.id); + } catch (error) { + if (generation === this.credentialGeneration && error instanceof ApiError && CREDENTIAL_GONE_CODES.has(error.code ?? "")) this.discardCredential(card.id); + else if (generation === this.credentialGeneration && route !== "credential-request") this.showNotice(this.strings.credential.unavailable); + } + finally { this.credentialRequestsInFlight.delete(controller); if (generation === this.credentialGeneration) { this.credentialBusy.delete(card.id); this.renderLog(); } } + } + + private async openCredential(card: CredentialRequestView): Promise { + if (this.credentialCards.get(card.id) !== card || this.credentialBusy.has(card.id) || this.credentialPopups.has(card.id)) return; + let popup: Window | null; + try { popup = window.open("about:blank", `dewee-credential-${crypto.randomUUID()}`, "popup,width=720,height=800"); } + catch { popup = null; } + if (!popup) { this.showNotice(this.strings.credential.unavailable); return; } + const generation = this.credentialGeneration, agent = this.agentId, conversation = this.conversationId; + const active = { popup, controller: new AbortController(), timer: undefined as ReturnType | undefined }; + this.credentialPopups.set(card.id, active); + this.credentialBusy.add(card.id); this.renderLog(); + try { + popup.opener = null; + const result = await this.client().credentialRequest("credential-request-handoff", agent, conversation, card.id, active.controller.signal); + if (generation !== this.credentialGeneration || popup.closed || !("url" in result)) throw new Error("cancelled"); + const meta = popup.document.createElement("meta"); meta.name = "referrer"; meta.content = "no-referrer"; popup.document.head.append(meta); + popup.location.replace(result.url); + const poll = async () => { + if (generation !== this.credentialGeneration) return; + const before = this.credentialCards.get(card.id); + if (before && (before.status === "pending" || before.status === "applying")) await this.updateCredential(before, "credential-request"); + const current = this.credentialCards.get(card.id); + if (generation === this.credentialGeneration && !popup!.closed && current && Date.parse(current.expires_at) > Date.now() && this.credentialPopups.get(card.id) === active) { + if (current.status === "pending" || current.status === "applying") active.timer = setTimeout(poll, 4000); + else { this.closeCredentialPopup(card.id); this.renderLog(); } + } else { if (this.credentialPopups.get(card.id) === active) this.closeCredentialPopup(card.id); if (generation === this.credentialGeneration) this.renderLog(); } + }; + active.timer = setTimeout(poll, 4000); + } catch { if (this.credentialPopups.get(card.id) === active) this.closeCredentialPopup(card.id); if (generation === this.credentialGeneration) this.showNotice(this.strings.credential.unavailable); } + finally { if (generation === this.credentialGeneration) { this.credentialBusy.delete(card.id); this.renderLog(); } } + } + + private async resumeCredential(card: CredentialRequestView): Promise { + if (this.credentialCards.get(card.id) !== card || this.running || this.credentialBusy.has(card.id)) return; + const generation = this.credentialGeneration, agent = this.agentId, conversation = this.conversationId; + const reply: ViewMessage = { role: "assistant", content: "", status: "streaming", startedAt: Date.now() }; + const controller = new AbortController(); + this.credentialBusy.add(card.id); this.pushMessage(reply); this.setRunning(controller); + try { + await this.client().chat({ message: "", agent, conversationId: conversation, credentialRequestId: card.id }, (event) => { if (generation === this.credentialGeneration) this.onStreamEvent(reply, event); }, this.running!.signal); + if (reply.status === "streaming") reply.status = "done"; + if (generation === this.credentialGeneration) { this.credentialBusy.delete(card.id); await this.updateCredential(card, "credential-request"); } + } catch (error) { + if (generation === this.credentialGeneration) { + this.messages = this.messages.filter((message) => message !== reply); + if (error instanceof ApiError && CREDENTIAL_GONE_CODES.has(error.code ?? "")) this.discardCredential(card.id); + else if (error instanceof ApiError && error.code === "credential_request_resumed") { this.credentialBusy.delete(card.id); await this.updateCredential(card, "credential-request"); } + else this.showNotice(this.strings.credential.unavailable); + } + } + finally { if (this.running === controller) this.setRunning(null); if (generation === this.credentialGeneration) { this.credentialBusy.delete(card.id); this.updateMessage(reply); this.renderLog(); } } + } + private renderWelcome(): HTMLElement { const c = this.config; const s = this.strings; @@ -1209,6 +1354,10 @@ export class DeweeChatElement extends ElementBase { t.isError = e.isError; break; } + case "credential_request": + this.credentialCards.set(e.credentialRequest.id, e.credentialRequest); + this.renderLog(); + break; case "done": this.finishThinking(m); if (e.content && e.content.length >= m.content.length) m.content = e.content; diff --git a/packages/dewee-webchat/src/widget/i18n.ts b/packages/dewee-webchat/src/widget/i18n.ts index eef223a..07a54f0 100644 --- a/packages/dewee-webchat/src/widget/i18n.ts +++ b/packages/dewee-webchat/src/widget/i18n.ts @@ -51,6 +51,7 @@ export interface Strings { pageShare: string; pageChanged: string; pageUnavailable: string; + credential: { open: string; cancel: string; continue: string; unavailable: string; intro: string; required: string; optional: string; expires: string; status: Record<"pending" | "applying" | "resolved" | "cancelled" | "expired" | "failed", string> }; } const en: Strings = { @@ -115,6 +116,7 @@ const en: Strings = { pageShare: "Share this page with the assistant for this message", pageChanged: "The page changed. Review it and choose to share again before sending.", pageUnavailable: "Page details are unavailable. You can send without them.", + credential: { open: "Open secure form", cancel: "Cancel request", continue: "Continue conversation", unavailable: "Could not open or update the request. Please try again.", intro: "Enter credentials only on the authenticated Dewee form.", required: "required", optional: "optional", expires: "Expires", status: { pending: "Waiting for you", applying: "Processing", resolved: "Completed", cancelled: "Cancelled", expired: "Expired", failed: "Failed" } }, }; const vi: Strings = { @@ -179,6 +181,7 @@ const vi: Strings = { pageShare: "Chia sẻ trang này với trợ lý trong tin nhắn này", pageChanged: "Trang đã thay đổi. Hãy xem lại và chọn chia sẻ trước khi gửi.", pageUnavailable: "Không thể đọc nội dung trang. Bạn vẫn có thể gửi tin nhắn.", + credential: { open: "Mở biểu mẫu bảo mật", cancel: "Hủy yêu cầu", continue: "Tiếp tục trò chuyện", unavailable: "Chưa thể mở hoặc cập nhật yêu cầu. Bạn thử lại nhé.", intro: "Chỉ nhập thông tin xác thực trên biểu mẫu Dewee sau khi đăng nhập.", required: "bắt buộc", optional: "tùy chọn", expires: "Hết hạn", status: { pending: "Đang chờ bạn", applying: "Đang xử lý", resolved: "Đã hoàn tất", cancelled: "Đã hủy", expired: "Đã hết hạn", failed: "Không thành công" } }, }; export const locales: Record = { en, vi }; diff --git a/packages/dewee-webchat/src/widget/styles.ts b/packages/dewee-webchat/src/widget/styles.ts index 5f66915..87a72ee 100644 --- a/packages/dewee-webchat/src/widget/styles.ts +++ b/packages/dewee-webchat/src/widget/styles.ts @@ -241,6 +241,10 @@ details > summary svg.ic { width: 14px; height: 14px; flex: none; } .btn.allow { background: var(--dw-accent); border-color: var(--dw-accent); color: var(--dw-accent-contrast); } .btn.allow.danger { background: #c2334f; border-color: #c2334f; color: #fff; } .btn:focus-visible { outline: 3px solid color-mix(in srgb, var(--dw-accent) 45%, transparent); outline-offset: 2px; } +.credential-request .bubble { overflow-wrap: anywhere; max-width: 100%; } +.credential-request p { margin: 8px 0; } +.credential-request ul { margin: 8px 0; padding-inline-start: 20px; } +.credential-actions { display: flex; flex-wrap: wrap; gap: 8px; } /* ---------- composer ---------- */ .composer { padding: 10px 12px 12px; border-top: 1px solid var(--dw-border); background: var(--dw-bg); } diff --git a/packages/dewee-webchat/test/ccp-credential-request.test.ts b/packages/dewee-webchat/test/ccp-credential-request.test.ts new file mode 100644 index 0000000..2962b3f --- /dev/null +++ b/packages/dewee-webchat/test/ccp-credential-request.test.ts @@ -0,0 +1,96 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { createDeweeHandler } from "../src/server"; +import { deweeIdentitySdk } from "../src/server/identity"; +import { CcpError } from "../src/server/ccp-client"; +import { browserErrorFor } from "../src/server/ccp-transport"; +import { APP_ID, ASSERTION_SECRET, INTEGRATION_ID, INTEGRATION_SECRET, ISSUER, startFakeCcp, type FakeCcp } from "./helpers/fake-ccp-ingress"; + +const base = "https://shop.example/api/dewee"; +const id = "0b7e4c1e-2f6a-4d1b-9c3e-5a8f7d6e4b21"; +const card = { id, schema_version: "webchat-credential-request.v1", status: "pending", title: "Connect", purpose: "Provide key", target: {type:"provider",mode:"create"}, fields: [{key:"api_key",label:"Key",kind:"secret",required:true}], expires_at: new Date(Date.now()+3600000).toISOString(), agent_key:"shop", completion:"hosted", resumable:false }; +let ccp: FakeCcp; +let calls: Array<{ route: string; headers: Headers; body: any }>; +let malicious = false; +let agentDisabled = false; +beforeAll(async () => { ccp = await startFakeCcp(); }); +afterAll(async () => { await ccp.close(); }); +beforeEach(() => { ccp.reset(); ccp.state.config = {...ccp.state.config, mode:"signed_in_optional", identity_enabled:true, credential_requests:true}; calls=[]; malicious=false; agentDisabled=false; }); +function handler(enabled=true, identityAgent?:string, formOrigin="https://app.example") { + const customFetch: typeof fetch = async (input, init) => { + const url=String(input); const route=url.split("/").pop()!; + if (route.startsWith("credential-request")) { + const body=JSON.parse(String(init?.body)); calls.push({route,headers:new Headers(init?.headers),body}); + if (agentDisabled) return Response.json({error:{code:"credential_requests_unavailable",message:"private"}},{status:403}); + if (route === "credential-request-handoff") return Response.json({credential_request_id:id,url: malicious ? "https://evil.example/credential-requests/open?token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" : "https://app.example/credential-requests/open?token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",expires_at:card.expires_at}); + return Response.json({credential_request:{...card, value:"must-not-leak"}}); + } + return fetch(input,init); + }; + return createDeweeHandler({ccp:{chatUrl:ccp.url,integrationId:INTEGRATION_ID,integrationSecret:INTEGRATION_SECRET,fetch:customFetch},secret:"visitor-secret-NEVER-IN-BROWSER-00000000001",rateLimit:false, + identity:{sdk:deweeIdentitySdk,authenticate: async req => req.headers.get("cookie") === "uid=alice" ? {subject:"alice"}:null, appId:APP_ID,issuer:ISSUER,assertionSecret:ASSERTION_SECRET,...(identityAgent ? {agent:identityAgent}: {})}, + ...(enabled ? {credentialRequests:{formOrigin}} : {})}); +} +async function session(h:ReturnType, signed=true) { + const r=await h(new Request(base+"/session",{method:"POST",headers:signed?{cookie:"uid=alice"}:{},body:"{}"})); return (await r.json()).token as string; +} +function request(token:string, route:string, extraHeaders:Record={}, body:unknown={agent:"shop",conversationId:"conversation_01",credentialRequestId:id}) { + return new Request(base+"/"+route,{method:"POST",headers:{authorization:`Bearer ${token}`,origin:"https://shop.example",cookie:"uid=alice",...extraHeaders},body:JSON.stringify(body)}); +} + +describe("CCP credential handoff", () => { + it("requires an ASCII HTTPS origin, allowing explicit punycode", () => { + expect(()=>handler(true,undefined,"https://bücher.example")).toThrow(/ASCII HTTPS origin/); + expect(()=>handler(true,undefined,"https://%61pp.example")).toThrow(/ASCII HTTPS origin/); + expect(()=>handler(true,undefined,"https://xn--bcher-kva.example")).not.toThrow(); + }); + it.each([["credential_requests_unavailable",403],["credential_request_not_found",404],["credential_request_not_ready",409],["credential_request_resumed",409]] as const)("preserves safe refusal %s", (code,status) => { + expect(browserErrorFor(new CcpError(status,code,"private upstream text"),"credential-request")).toEqual({status,code,message:expect.not.stringContaining("private upstream text")}); + }); + it("requires explicit opt-in, verified login, matching user and allowed origin", async () => { + const off=handler(false); const signed=await session(off); expect((await off(request(signed,"credential-request"))).status).toBe(404); + const h=handler(); const anonymous=await session(h,false); expect((await h(request(anonymous,"credential-request"))).status).toBe(401); + const token=await session(h); expect((await h(request(token,"credential-request",{cookie:"uid=bob"}))).status).toBeGreaterThanOrEqual(400); + expect((await h(request(token,"credential-request",{origin:"https://evil.example"}))).status).toBe(403); + expect(calls).toHaveLength(0); + }); + it("forwards verified identity only to CCP, filters the card, pins the destination and uses no-store", async () => { + const h=handler(); const token=await session(h); + const status=await h(request(token,"credential-request")); const data=await status.json(); + expect(status.status).toBe(200); expect(status.headers.get("cache-control")).toBe("no-store"); + expect(data.credentialRequest).toEqual(card); expect(JSON.stringify(data)).not.toContain("must-not-leak"); + expect(calls[0].headers.get("x-dewee-webchat-features")).toBe("credential-requests"); expect(calls[0].body.identity_session).toMatch(/^idsess_/); + const handoff=await h(request(token,"credential-request-handoff")); expect(handoff.status).toBe(200); + expect((await handoff.json()).url).toBe("https://app.example/credential-requests/open?token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + malicious=true; const bad=await h(request(token,"credential-request-handoff")); expect(bad.status).toBe(502); expect(await bad.text()).not.toContain("evil.example"); + expect((await h(request(token,"credential-request",{}, {agent:"support",conversationId:"conversation_01",credentialRequestId:id}))).status).toBe(403); + }); + it("forwards authorized metadata in SSE and history and resumes with no chat text", async () => { + const h=handler(); const token=await session(h); + ccp.state.credentialCard = { ...card, fields: [{ ...card.fields[0], value: "never-forward" }] }; + const headers = { authorization:`Bearer ${token}`, origin:"https://shop.example", cookie:"uid=alice" }; + const chat = await h(new Request(`${base}/chat`, { method:"POST", headers, body:JSON.stringify({ agent:"shop", conversationId:"conversation_01", message:"hello" }) })); + expect(chat.headers.get("cache-control")).toContain("no-store"); + const stream = await chat.text(); expect(stream).toContain('"type":"credential_request"'); expect(stream).not.toContain("never-forward"); + const history = await h(new Request(`${base}/history?agent=shop&conversationId=conversation_01`, { headers })); + expect((await history.json()).credentialRequests[0].id).toBe(id); + const resume = await h(request(token,"chat",{}, {agent:"shop",conversationId:"conversation_01",message:"",credentialRequestId:id})); + expect(resume.status).toBe(200); await resume.text(); + expect(ccp.routes("chat").at(-1)?.body).toMatchObject({message:"",conversation_id:"conversation_01",credential_request_id:id}); + expect(ccp.routes("chat").at(-1)?.headers["x-dewee-webchat-features"]).toBe("credential-requests"); + const before=ccp.routes("chat").length; + expect((await h(request(token,"chat",{}, {agent:"shop",conversationId:"conversation_01",message:"secret",credentialRequestId:id}))).status).toBe(400); + expect(ccp.routes("chat")).toHaveLength(before); + }); + it("lets the runtime authorize the selected agent even when default-agent capability differs", async () => { + ccp.state.config = {...ccp.state.config,credential_requests:false}; + const h=handler(true,"support"); const token=await session(h); + ccp.state.credentialCard = {...card,agent_key:"support"}; + const headers = { authorization:`Bearer ${token}`, origin:"https://shop.example", cookie:"uid=alice" }; + const chat=await h(new Request(`${base}/chat`,{method:"POST",headers,body:JSON.stringify({agent:"support",conversationId:"conversation_01",message:"hello"})})); + expect(await chat.text()).toContain('"type":"credential_request"'); + ccp.state.config = {...ccp.state.config,credential_requests:true}; agentDisabled=true; + const other=handler(true,"support"); const otherToken=await session(other); + const denied=await other(request(otherToken,"credential-request",{}, {agent:"support",conversationId:"conversation_01",credentialRequestId:id})); + expect(denied.status).toBe(403); expect((await denied.json()).error.code).toBe("credential_requests_unavailable"); + }); +}); diff --git a/packages/dewee-webchat/test/credential-request.test.ts b/packages/dewee-webchat/test/credential-request.test.ts new file mode 100644 index 0000000..33f9e61 --- /dev/null +++ b/packages/dewee-webchat/test/credential-request.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parseCredentialRequest, parseCredentialHandoff } from "../src/credential-request"; + +const id = "0b7e4c1e-2f6a-4d1b-9c3e-5a8f7d6e4b21"; +const expiry = new Date(Date.now() + 3_600_000).toISOString(); +const card = { id, schema_version: "webchat-credential-request.v1", status: "pending", title: "Connect", purpose: "Provide a key", target: { type: "provider", mode: "create" }, fields: [{ key: "api_key", label: "API key", kind: "secret", required: true }], expires_at: expiry, agent_key: "support", completion: "hosted", resumable: false }; + +describe("credential request boundary", () => { + it("rebuilds known metadata and discards unexpected fields", () => { + const parsed = parseCredentialRequest({ ...card, secret: "not-forwarded", fields: [{ ...card.fields[0], value: "never-forwarded" }] }, "support"); + expect(parsed).toEqual(card); + }); + it("rejects wrong agent, schema, status, malformed fields and non-hosted requests", () => { + for (const bad of [{...card, agent_key: "other"}, {...card, schema_version: "other"}, {...card, status: "unknown"}, {...card, completion: "inline"}, {...card, fields: [{...card.fields[0], required: "yes"}]}, {...card, id: "invalid"}]) { + expect(parseCredentialRequest(bad, "support")).toBeNull(); + } + }); + it("accepts only a matching id and explicitly trusted HTTPS origin", () => { + const answer = { credential_request_id: id, url: "https://app.example/credential-requests/open?token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", expires_at: expiry }; + expect(parseCredentialHandoff(answer, id, "https://app.example")).toEqual(answer); + for (const url of ["https://evil.example/l/x", "http://app.example/l/x", "https://app.example@evil.example/l/x", "https://u:p@app.example/l/x", "javascript:alert(1)"]) { + expect(parseCredentialHandoff({ ...answer, url }, id, "https://app.example")).toBeNull(); + } + expect(parseCredentialHandoff(answer, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "https://app.example")).toBeNull(); + }); +}); diff --git a/packages/dewee-webchat/test/helpers/fake-ccp-ingress.ts b/packages/dewee-webchat/test/helpers/fake-ccp-ingress.ts index 4c7c66a..4d00161 100644 --- a/packages/dewee-webchat/test/helpers/fake-ccp-ingress.ts +++ b/packages/dewee-webchat/test/helpers/fake-ccp-ingress.ts @@ -34,6 +34,8 @@ export interface FakeCcpState { /** Conversations by `${actor}|${conversationId}`. */ conversations: Map>; edgeTtlSeconds: number; + credentialCard?: Record; + credentialHandoffUrl?: string; } const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url"); @@ -206,6 +208,7 @@ export async function startFakeCcp(): Promise { return res.end(); } if (cfg.show_thinking || state.leaky) frame({ type: "thinking", text: "secret plan" }); + if (state.credentialCard && actor.startsWith("tuc:") && req.headers["x-dewee-webchat-features"] === "credential-requests") frame({ type: "credential_request", credential_request: state.credentialCard }); if (cfg.show_tool_activity || state.leaky) { frame({ type: "tool_call", id: "t1", name: "search_products", arguments: { q: "internal" } }); frame({ type: "tool_result", id: "t1", name: "search_products", isError: false, result: "internal rows" }); @@ -219,7 +222,7 @@ export async function startFakeCcp(): Promise { if (route === "history") { const conv = state.conversations.get(`${actor}|${body?.conversation_id}`); if (!conv) return error(res, 404, "not_found"); - return send(res, 200, { conversation_id: body.conversation_id, messages: [...conv, { role: "tool", content: "raw tool output" }] }); + return send(res, 200, { conversation_id: body.conversation_id, messages: [...conv, { role: "tool", content: "raw tool output" }], ...(state.credentialCard && actor.startsWith("tuc:") && req.headers["x-dewee-webchat-features"] === "credential-requests" ? { credential_requests: [state.credentialCard] } : {}) }); } if (route === "abort") return send(res, 200, { aborted: true }); if (route === "identity/exchange") { @@ -244,6 +247,11 @@ export async function startFakeCcp(): Promise { if (route === "job-access") { return send(res, 200, { bootstrap_url: "https://access.example/identity/job-access/launch", launch_token: "one_time_ticket_0123456789", expires_at: new Date(Date.now() + 45_000).toISOString() }); } + if (["credential-request", "credential-request-handoff", "credential-request-cancel"].includes(route)) { + if (!actor.startsWith("tuc:") || req.headers["x-dewee-webchat-features"] !== "credential-requests" || !state.credentialCard || !state.conversations.has(`${actor}|${body?.conversation_id}`) || body?.credential_request_id !== state.credentialCard.id) return error(res, 404, "credential_request_not_found"); + if (route === "credential-request-handoff") return send(res, 200, { credential_request_id: state.credentialCard.id, url: state.credentialHandoffUrl ?? "https://app.example/credential-requests/open?token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", expires_at: state.credentialCard.expires_at }); + return send(res, 200, { credential_request: { ...state.credentialCard, ...(route === "credential-request-cancel" ? { status: "cancelled" } : {}) } }); + } return error(res, 404, "not_found"); } @@ -263,6 +271,8 @@ export async function startFakeCcp(): Promise { state.redirectTo = undefined; state.leaky = false; state.edgeTtlSeconds = 3600; + state.credentialCard = undefined; + state.credentialHandoffUrl = undefined; }, routes: (name) => seen.filter((r) => r.route === name), close: () => diff --git a/packages/dewee-webchat/test/legacy-gateway-characterization.test.ts b/packages/dewee-webchat/test/legacy-gateway-characterization.test.ts index e5199bf..7adcb02 100644 --- a/packages/dewee-webchat/test/legacy-gateway-characterization.test.ts +++ b/packages/dewee-webchat/test/legacy-gateway-characterization.test.ts @@ -37,12 +37,22 @@ beforeAll(async () => { ev("thinking", { content: "Let me check." }); ev("tool.call", { id: "t1", name: "search", arguments: { q: "x" } }); ev("tool.result", { id: "t1", name: "search", is_error: false, result: "found" }); + if (f.params.message.endsWith("credential-test")) { + ev("tool.call", { id: "s1", name: "request_credentials", arguments: { value: "credential-argument-secret" } }); + ev("tool.call", { id: "s1", name: "search", arguments: { value: "reused-call-secret" } }); + ev("tool.result", { id: "s1", result: "credential-result-secret" }); + ev("tool.result", { id: "unknown", result: "unmatched-result-secret" }); + } ev("chunk", { content: "Hello " }); ev("chunk", { content: "world" }); ev("run.completed", { content: "Hello world" }); return res({ content: "Hello world", usage: { total_tokens: 42 } }); } if (f.method === "chat.history") { + if (String(f.params.sessionKey).includes("conv_cred")) return res({ messages: [ + { role: "assistant", content: "", tool_calls: [{ id: "s1", name: "request_credentials", arguments: { value: "credential-argument-secret" } }, { id: "s1", name: "search", arguments: { value: "reused-call-secret" } }, { name: "request_credentials" }, { name: "search" }, { id: "safe", name: "search", arguments: { q: "public" } }] }, + { role: "tool", content: "credential-result-secret", tool_call_id: "s1" }, { role: "tool", content: "missing-id-secret" }, { role: "tool", content: "public-result", tool_call_id: "safe" }, + ] }); return res({ messages: [ { role: "user", content: "hi" }, @@ -91,6 +101,15 @@ async function visitor(h: ReturnType) { } describe("legacy-gateway transport: browser responses", () => { + it("does not expose credential tool arguments or results in stream and history", async () => { + const h=createDeweeHandler(opts()); const s=await visitor(h); const headers={authorization:`Bearer ${s.token}`}; + const response=await h(new Request(`${BASE}/chat`,{method:"POST",headers,body:JSON.stringify({message:"credential-test",agent:"support",conversationId:"conv_cred"})})); + const stream=await response.text(); + for (const secret of ["credential-argument-secret","reused-call-secret","credential-result-secret","unmatched-result-secret"]) expect(stream).not.toContain(secret); + expect(stream).toContain('"result":"found"'); + const history=await (await h(new Request(`${BASE}/history?agent=support&conversationId=conv_cred`,{headers}))).text(); + expect(history).not.toContain("secret"); expect(history).toContain("public-result"); + }); it("negotiates and forwards page context only when the connected runtime supports it", async () => { const h = createDeweeHandler(opts({ agents: [{ id: "support", name: "Support" }] })); const context = { version: 1, url: "https://site.test/page", content: { format: "text", text: "A page", truncated: false } }; diff --git a/packages/dewee-webchat/test/php-identity-ccp.test.ts b/packages/dewee-webchat/test/php-identity-ccp.test.ts index 8d3dafb..4af59c9 100644 --- a/packages/dewee-webchat/test/php-identity-ccp.test.ts +++ b/packages/dewee-webchat/test/php-identity-ccp.test.ts @@ -1,5 +1,5 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -15,23 +15,24 @@ const source = resolve(__dirname, "../../../php/dewee-webchat.php"); const secret = "fixture-visitor-secret-at-least-32-bytes"; describe.skipIf(!available)("PHP signed-in CCP sessions", () => { - let ccp: FakeCcp, server: ChildProcess, base: string; + let ccp: FakeCcp, server: ChildProcess, base: string, temp: string; beforeAll(async () => { ccp = await startFakeCcp(); const dir = mkdtempSync(join(tmpdir(), "webchat-identity-")); + temp = join(dir, "tmp"); mkdirSync(temp); const options = { ccp: { chatUrl: ccp.url, integrationId: INTEGRATION_ID, integrationSecret: INTEGRATION_SECRET }, secret, rateLimit: false, allowedOrigins: '*', identity: { appId: APP_ID, issuer: ISSUER, assertionSecret: ASSERTION_SECRET, rebindEachRequest: true } }; writeFileSync(join(dir, "options.json"), JSON.stringify(options)); - writeFileSync(join(dir, "router.php"), `$u] : null; };\nif (str_ends_with($_SERVER['REQUEST_URI'], '/logout')) { $body=json_decode(file_get_contents('php://input'),true); DeweeWebchat::revoke($_COOKIE['uid'] ?? '', $body['token'] ?? '', $o); echo '{}'; return; }\nDeweeWebchat::handle($o);\n`); + writeFileSync(join(dir, "router.php"), `'https://app.example'];\nif (($_SERVER['HTTP_X_TEST_FEATURE'] ?? '') === 'support') $o['identity']['agent']='support';\nif (($_SERVER['HTTP_X_TEST_FEATURE'] ?? '') === 'unicode') $o['credentialRequests']=['formOrigin'=>'https://bücher.example'];\nif (($_SERVER['HTTP_X_TEST_FEATURE'] ?? '') === 'punycode') $o['credentialRequests']=['formOrigin'=>'https://xn--bcher-kva.example'];\n$o['identity']['authenticate']=static function(){ $u=$_COOKIE['uid'] ?? null; if ($u === 'boom') throw new RuntimeException('private login error'); return $u ? ['subject'=>$u] : null; };\nif (str_ends_with($_SERVER['REQUEST_URI'], '/logout')) { $body=json_decode(file_get_contents('php://input'),true); DeweeWebchat::revoke($_COOKIE['uid'] ?? '', $body['token'] ?? '', $o); echo '{}'; return; }\nDeweeWebchat::handle($o);\n`); const port = await new Promise((done) => { const s = createServer().listen(0, "127.0.0.1", () => { const p = (s.address() as { port: number }).port; s.close(() => done(p)); }); }); base = `http://127.0.0.1:${port}/api/dewee`; - server = spawn(php, ["-S", `127.0.0.1:${port}`, join(dir, "router.php")], { stdio: "ignore" }); + server = spawn(php, ["-d", `sys_temp_dir=${temp}`, "-S", `127.0.0.1:${port}`, join(dir, "router.php")], { stdio: "ignore" }); for (let i = 0; i < 100; i++) { try { await fetch(`${base}/config`); return; } catch { await new Promise((r) => setTimeout(r, 100)); } } throw new Error("PHP server did not start"); }, 30000); afterAll(async () => { server?.kill("SIGTERM"); await ccp?.close(); }); - beforeEach(() => { ccp.reset(); ccp.state.config = { ...ccp.state.config, mode: "signed_in_optional", identity_enabled: true }; }); + beforeEach(() => { ccp.reset(); ccp.state.config = { ...ccp.state.config, mode: "signed_in_optional", identity_enabled: true }; for (const name of readdirSync(temp).filter(x => /^dewee-webchat-\d+$/.test(x))) for (const file of readdirSync(join(temp,name)).filter(x=>x.startsWith("ccp-config-"))) rmSync(join(temp,name,file),{force:true}); }); const session = (uid?: string) => fetch(`${base}/session`, { method: "POST", headers: uid ? { cookie: `uid=${uid}` } : {}, body: "{}" }); it("exchanges a signed-in host user and keeps other users isolated", async () => { @@ -51,6 +52,11 @@ describe.skipIf(!available)("PHP signed-in CCP sessions", () => { expect(JSON.stringify(a)).not.toContain(ca!.ids!); }); + it("requires an ASCII credential form origin and accepts punycode", async () => { + expect((await fetch(`${base}/config`, {headers:{"x-test-feature":"unicode"}})).status).toBe(500); + expect((await fetch(`${base}/config`, {headers:{"x-test-feature":"punycode"}})).status).toBe(200); + }); + it("fails closed when the host login check throws", async () => { const response = await session("boom"); expect(response.status).toBe(503); @@ -88,6 +94,46 @@ describe.skipIf(!available)("PHP signed-in CCP sessions", () => { expect(ccp.routes("identity/revoke")).toHaveLength(1); }); + it("mirrors the hosted credential request contract without exposing values", async () => { + ccp.state.config = { ...ccp.state.config, credential_requests: true }; + const id = "0b7e4c1e-2f6a-4d1b-9c3e-5a8f7d6e4b21"; + const card = { id, schema_version: "webchat-credential-request.v1", status: "pending", title: "Connect", purpose: "Provide key", target: { type: "provider", mode: "create" }, fields: [{ key: "api_key", label: "Key", kind: "secret", required: true, value: "never-forward" }], expires_at: new Date(Date.now()+3600000).toISOString(), agent_key: "shop", completion: "hosted", resumable: false }; + ccp.state.credentialCard = card; + const issued = await (await session("user-a")).json(); + const headers = { cookie: "uid=user-a", origin: "https://shop.example", authorization: `Bearer ${issued.token}`, "x-test-feature": "on" }; + const chat = await fetch(`${base}/chat`, { method: "POST", headers, body: JSON.stringify({ message: "hello", conversationId: "conv_php_cred_1", agent: "shop" }) }); + expect(chat.headers.get("cache-control")).toContain("no-store"); + const text = await chat.text(); expect(text).toContain('"type":"credential_request"'); expect(text).not.toContain("never-forward"); + const history = await fetch(`${base}/history?agent=shop&conversationId=conv_php_cred_1`, { headers }); + const stored = await history.json(); expect(stored.credentialRequests[0].id).toBe(id); expect(JSON.stringify(stored)).not.toContain("never-forward"); + const body = JSON.stringify({ agent: "shop", conversationId: "conv_php_cred_1", credentialRequestId: id }); + const status = await fetch(`${base}/credential-request`, { method: "POST", headers, body }); + expect(status.status).toBe(200); expect((await status.json()).credentialRequest.id).toBe(id); + const foreign = await fetch(`${base}/credential-request`, { method: "POST", headers, body: JSON.stringify({ agent: "shop", conversationId: "foreign_conv_1", credentialRequestId: id }) }); + expect(foreign.status).toBe(404); expect((await foreign.json()).error.code).toBe("credential_request_not_found"); + const handoff = await fetch(`${base}/credential-request-handoff`, { method: "POST", headers, body }); + expect(handoff.status).toBe(200); expect(handoff.headers.get("cache-control")).toBe("no-store"); expect((await handoff.json()).url).toBe("https://app.example/credential-requests/open?token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + const resume = await fetch(`${base}/chat`, { method: "POST", headers, body: JSON.stringify({ agent: "shop", conversationId: "conv_php_cred_1", message: "", credentialRequestId: id }) }); + expect(resume.status).toBe(200); await resume.text(); expect(ccp.routes("chat").at(-1)?.body).toMatchObject({ message: "", credential_request_id: id }); + expect((await fetch(`${base}/chat`, { method: "POST", headers, body: JSON.stringify({ agent: "shop", conversationId: "conv_php_cred_1", message: "do not accept", credentialRequestId: id }) })).status).toBe(400); + ccp.state.credentialHandoffUrl = "https://evil.example/credential-requests/open?token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + expect((await fetch(`${base}/credential-request-handoff`, { method: "POST", headers, body })).status).toBe(502); + expect((await fetch(`${base}/credential-request`, { method: "POST", headers: { ...headers, cookie: "uid=user-b" }, body })).status).toBe(401); + }); + + it.each([false, true])("defers selected-agent credential policy to the runtime when default capability is %s", async (defaultEnabled) => { + ccp.state.config = { ...ccp.state.config, credential_requests: defaultEnabled }; + const id = "0b7e4c1e-2f6a-4d1b-9c3e-5a8f7d6e4b21"; + ccp.state.credentialCard = { id, schema_version:"webchat-credential-request.v1", status:"pending", title:"Connect", purpose:"Key", target:{type:"provider",mode:"create"}, fields:[], expires_at:new Date(Date.now()+3600000).toISOString(), agent_key:"support", completion:"hosted", resumable:false }; + const issued = await (await fetch(`${base}/session`, { method:"POST", headers:{cookie:"uid=user-a", "x-test-feature":"support"}, body:"{}" })).json(); + const headers = {cookie:"uid=user-a", origin:"https://shop.example", authorization:`Bearer ${issued.token}`, "x-test-feature":"support"}; + const chat = await fetch(`${base}/chat`, {method:"POST",headers,body:JSON.stringify({agent:"support",conversationId:"conv_php_agent_1",message:"hi"})}); + expect(await chat.text()).toContain('"type":"credential_request"'); + if (defaultEnabled) ccp.state.failNext.set("credential-request", {status:403,code:"credential_requests_unavailable"}); + const status = await fetch(`${base}/credential-request`, {method:"POST",headers,body:JSON.stringify({agent:"support",conversationId:"conv_php_agent_1",credentialRequestId:id})}); + expect(status.status).toBe(defaultEnabled ? 403 : 200); + }); + it("starts account linking only for the authenticated session", async () => { const issued = await (await session("user-a")).json(); const response = await fetch(`${base}/link`, { method: "POST", headers: { cookie: "uid=user-a", authorization: `Bearer ${issued.token}` }, body: "{}" }); diff --git a/packages/dewee-webchat/test/php-identity-gateway.test.ts b/packages/dewee-webchat/test/php-identity-gateway.test.ts index 13b0ba1..2e978a9 100644 --- a/packages/dewee-webchat/test/php-identity-gateway.test.ts +++ b/packages/dewee-webchat/test/php-identity-gateway.test.ts @@ -48,6 +48,16 @@ describe.skipIf(!available)("PHP signed-in Gateway sessions", () => { if (frame.method === "chat.send") lastChat = frame.params; if (frame.method === "chat.send" && pageContextRefusal) { socket.send(JSON.stringify({ type: "res", id: frame.id, ok: false, error: { code: "INVALID_REQUEST", message: "private page content", details: { reason: "page_context_invalid" } } })); return; } if (frame.method === "connect" && wsFailure) { socket.send(JSON.stringify({ type: "res", id: frame.id, ok: false, error: wsFailure })); return; } + if (frame.method === "chat.send" && frame.params.message === "credential-test") { + const ev = (type:string, payload:unknown) => socket.send(JSON.stringify({type:"event",event:"agent",payload:{type,sessionKey:frame.params.sessionKey,payload}})); + ev("tool.call",{id:"safe",name:"search",arguments:{q:"visible"}}); ev("tool.result",{id:"safe",result:"public-result"}); + ev("tool.call",{id:"s1",name:"request_credentials",arguments:{value:"credential-argument-secret"}}); + ev("tool.call",{id:"s1",name:"search",arguments:{value:"reused-call-secret"}}); ev("tool.result",{id:"s1",result:"credential-result-secret"}); + ev("tool.result",{id:"unknown",result:"unmatched-result-secret"}); + } + if (frame.method === "chat.history" && String(frame.params.sessionKey).includes("conv_cred")) { + socket.send(JSON.stringify({type:"res",id:frame.id,ok:true,payload:{messages:[{role:"assistant",content:"",tool_calls:[{id:"s1",name:"request_credentials",arguments:{value:"credential-argument-secret"}},{id:"s1",name:"search",arguments:{value:"reused-call-secret"}},{name:"request_credentials"},{name:"search"},{id:"safe",name:"search",arguments:{q:"visible"}}]},{role:"tool",tool_call_id:"s1",content:"credential-result-secret"},{role:"tool",content:"missing-id-secret"},{role:"tool",tool_call_id:"safe",content:"public-result"}]}})); return; + } const payload = frame.method === "connect" ? (applied ? { user_id: "tuc:principal-test", ...(pageContextSupported ? { page_context_versions: [1] } : {}) } : {}) : frame.method === "chat.history" ? { messages: [] } : { content: "hello" }; socket.send(JSON.stringify({ type: "res", id: frame.id, ok: true, payload })); })); @@ -85,6 +95,17 @@ describe.skipIf(!available)("PHP signed-in Gateway sessions", () => { expect(await link.json()).toMatchObject({ proofCode: "123456", deepLinks: [{ channelType: "telegram" }] }); }); + it("keeps credential tool payloads out of legacy streams and history", async () => { + const issued=await (await fetch(`${base}/session`,{method:"POST",headers:{cookie:"uid=user-a"},body:"{}"})).json(); + const headers={cookie:"uid=user-a",authorization:`Bearer ${issued.token}`}; + const response=await fetch(`${base}/chat`,{method:"POST",headers,body:JSON.stringify({message:"credential-test",agent:"support",conversationId:"conv_cred"})}); + const stream=await response.text(); + for (const secret of ["credential-argument-secret","reused-call-secret","credential-result-secret","unmatched-result-secret"]) expect(stream).not.toContain(secret); + expect(stream).toContain("public-result"); + const history=await (await fetch(`${base}/history?agent=support&conversationId=conv_cred`,{headers})).text(); + expect(history).not.toContain("secret"); expect(history).toContain("public-result"); + }); + it("negotiates page context and binds it to the verified Gateway session", async () => { const issued = await (await fetch(`${base}/session`, { method: "POST", headers: { cookie: "uid=user-a" }, body: "{}" })).json(); const headers = { cookie: "uid=user-a", authorization: `Bearer ${issued.token}` }; diff --git a/packages/dewee-webchat/test/widget-credential-request.test.ts b/packages/dewee-webchat/test/widget-credential-request.test.ts new file mode 100644 index 0000000..ed33156 --- /dev/null +++ b/packages/dewee-webchat/test/widget-credential-request.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { defineDeweeChat, type DeweeChatElement } from "../src/index"; + +const id = "0b7e4c1e-2f6a-4d1b-9c3e-5a8f7d6e4b21"; +const secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const card = () => ({id,schema_version:"webchat-credential-request.v1",status:"pending",title:"Connect provider",purpose:"Allow access",target:{type:"provider",mode:"create"},fields:[{key:"api_key",label:"API key",kind:"secret",required:true}],expires_at:new Date(Date.now()+3600000).toISOString(),agent_key:"support",completion:"hosted",resumable:false}); +beforeAll(() => defineDeweeChat()); +afterEach(() => {document.body.replaceChildren(); localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers();}); + +function backend() { + let statusCalls = 0; + const json=(v:unknown)=>new Response(JSON.stringify(v),{headers:{"content-type":"application/json"}}); + const calls:Array<{path:string,body:any}>=[]; + vi.stubGlobal("fetch",vi.fn(async (url:string,init?:RequestInit)=>{ + const path=new URL(url,"https://site.test").pathname; const body=init?.body ? JSON.parse(String(init.body)):null; calls.push({path,body}); + if(path.endsWith("/config")) return json({identity:true,credentialFormOrigin:"https://app.example",agents:[{id:"support"}],defaultAgent:"support",maxMessageLength:4000}); + if(path.endsWith("/session")) return json({visitorId:"visitor1",token:"visitor-token",verified:true,expiresAt:Date.now()+3600000}); + if(path.endsWith("/history")) return json({messages:[],credentialRequests:[card()]}); + if(path.endsWith("/credential-request-handoff")) return json({credential_request_id:id,url:`https://app.example/credential-requests/open?token=${secret}`,expires_at:new Date(Date.now()+600000).toISOString()}); + if(path.endsWith("/credential-request")) { statusCalls++; return json({credentialRequest:{...card(),status:statusCalls===1?"applying":"resolved",resumable:statusCalls>1}}); } + if(path.endsWith("/credential-request-cancel")) return json({credentialRequest:{...card(),status:"cancelled"}}); + if(path.endsWith("/chat")) return new Response(`data: ${JSON.stringify({type:"credential_request",credentialRequest:card()})}\n\ndata: ${JSON.stringify({type:"done",content:"Done"})}\n\n`,{headers:{"content-type":"text/event-stream"}}); + throw new Error(`unexpected ${path}`); + })); + return calls; +} +async function mount(mode:string) {const el=document.createElement("dewee-chat") as DeweeChatElement; el.setAttribute("mode",mode);el.setAttribute("persist","false");const ready=new Promise(resolve=>el.addEventListener("dewee:ready",()=>resolve(),{once:true}));document.body.append(el);if(mode==="floating") el.open();await ready;await el.send("hello");return el;} + +describe.each(["inline","floating"])("credential card in %s mode", mode=>{ + it("shows only metadata, pins handoff to a popup, polls applying until resolved, and cleans up", async()=>{ + const calls=backend(); const el=await mount(mode); + expect(el.shadowRoot!.querySelector(".credential-request")?.textContent).toContain("API key (required)"); + const popupDoc=document.implementation.createHTMLDocument("handoff"); + const popup={closed:false,opener:window,document:popupDoc,location:{replace:vi.fn()},close:vi.fn()}; + vi.spyOn(window,"open").mockReturnValue(popup as unknown as Window); + vi.useFakeTimers(); + const button=el.shadowRoot!.querySelector(".credential-actions button")!; button.click(); + await vi.advanceTimersByTimeAsync(0); + expect(popup.opener).toBeNull(); expect(popup.location.replace).toHaveBeenCalledWith(`https://app.example/credential-requests/open?token=${secret}`); + expect(el.shadowRoot!.textContent).not.toContain(secret); expect(document.body.innerHTML).not.toContain(secret); + expect(JSON.stringify({...localStorage,...sessionStorage})).not.toContain(secret); + await vi.advanceTimersByTimeAsync(4000); expect(el.shadowRoot!.textContent).toContain("Processing"); + await vi.advanceTimersByTimeAsync(4000); expect(el.shadowRoot!.textContent).toContain("Completed"); + expect(el.shadowRoot!.textContent).toContain("Continue conversation"); + expect(calls.filter(x=>x.path.endsWith("/credential-request"))).toHaveLength(2); + el.newConversation(); expect(popup.close).toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); + }); +}); + +it("restores a card from history without persisting its metadata", async () => { + backend(); localStorage.setItem("dewee-webchat:conv:/api/dewee::support", "conversation_01"); + const el=document.createElement("dewee-chat") as DeweeChatElement; el.setAttribute("mode","inline"); + const ready=new Promise(resolve=>el.addEventListener("dewee:ready",()=>resolve(),{once:true})); document.body.append(el); await ready; + expect(el.shadowRoot!.querySelector(".credential-request")?.textContent).toContain("Connect provider"); + expect(JSON.stringify({...localStorage,...sessionStorage})).not.toContain("Connect provider"); +}); + +it("aborts a pending handoff and closes its popup when the conversation changes", async () => { + backend(); const original=globalThis.fetch; let finish!: (r:Response)=>void; let requestSignal:AbortSignal|undefined; + vi.stubGlobal("fetch",vi.fn((url:string,init?:RequestInit) => { + if (String(url).endsWith("/credential-request-handoff")) { requestSignal=init?.signal ?? undefined; return new Promise(resolve=>{finish=resolve;}); } + return original(url,init); + })); + const el=await mount("inline"); + const popup={closed:false,opener:window,document:document.implementation.createHTMLDocument("handoff"),location:{replace:vi.fn()},close:vi.fn()}; + vi.spyOn(window,"open").mockReturnValue(popup as unknown as Window); + el.shadowRoot!.querySelector(".credential-actions button")!.click(); + await vi.waitFor(()=>expect(requestSignal).toBeDefined()); el.newConversation(); + expect(requestSignal!.aborted).toBe(true); expect(popup.close).toHaveBeenCalledOnce(); + finish(Response.json({credential_request_id:id,url:`https://app.example/credential-requests/open?token=${secret}`,expires_at:new Date(Date.now()+600000).toISOString()})); + await new Promise(resolve=>setTimeout(resolve,0)); expect(popup.location.replace).not.toHaveBeenCalled(); +}); + +it("does not mint a handoff when the popup is blocked and can cancel the request", async () => { + const calls=backend(); const el=await mount("inline"); + vi.spyOn(window,"open").mockReturnValue(null); + el.shadowRoot!.querySelector(".credential-actions button")!.click(); + expect(calls.filter(x=>x.path.endsWith("/credential-request-handoff"))).toHaveLength(0); + const buttons=el.shadowRoot!.querySelectorAll(".credential-actions button"); buttons[1].click(); + await vi.waitFor(()=>expect(el.shadowRoot!.textContent).toContain("Cancelled")); + expect(calls.filter(x=>x.path.endsWith("/credential-request-cancel"))).toHaveLength(1); +}); diff --git a/php/README.md b/php/README.md index d9bfdc3..ea9ad1c 100644 --- a/php/README.md +++ b/php/README.md @@ -36,6 +36,12 @@ with the authenticated subject and the widget's `reuseHint()` or visitor token. best-effort revocation of the matching identity session; the direct Gateway transport has no revoke endpoint and its session expires normally. The hook does not replace the host's logout or CSRF checks. +For hosted credential requests, CCP and signed-in identity are required. Set +`$options['credentialRequests'] = ['formOrigin' => 'https://app.example.com'];` to the exact HTTPS +origin of the Dewee form, using ASCII punycode for an internationalized domain. The Gateway and agent must also enable the capability. The widget never +receives credential values; only an authenticated workspace approval administrator can submit them +on the hosted form. See the [HTTP contract](../docs/http-contract.md#hosted-credential-requests). + ## Install Copy `dewee-webchat.php` into your project and create an endpoint, for example `api/dewee.php`: diff --git a/php/dewee-webchat.php b/php/dewee-webchat.php index 892448c..4f7d965 100644 --- a/php/dewee-webchat.php +++ b/php/dewee-webchat.php @@ -126,6 +126,7 @@ final class DeweeWebchat private ?DeweeCcpClient $ccp = null; private ?array $identity = null; private string $identityAgent = ''; + private ?string $credentialFormOrigin = null; /** * Handles the current request and writes the response. @@ -312,6 +313,13 @@ private function __construct(array $options) $this->identity = $identity; $this->identityAgent = $agent; } + if (isset($this->o['credentialRequests'])) { + $options = $this->o['credentialRequests']; + $origin = is_array($options) ? ($options['formOrigin'] ?? null) : null; + $u = is_string($origin) ? parse_url($origin) : false; + if ($this->ccp === null || $this->identity === null || !is_string($origin) || preg_match('/[^\x21-\x7e]|[\\\\%]/', $origin) || !is_array($u) || strtolower($u['scheme'] ?? '') !== 'https' || empty($u['host']) || isset($u['user']) || isset($u['pass']) || isset($u['query']) || isset($u['fragment']) || !in_array($u['path'] ?? '', ['', '/'], true)) throw new DeweeConfigError('[dewee-webchat] credentialRequests requires CCP identity and an ASCII HTTPS form origin (use punycode for internationalized domains)'); + $this->credentialFormOrigin = 'https://' . strtolower($u['host']) . (isset($u['port']) && $u['port'] !== 443 ? ':' . $u['port'] : ''); + } } /** @@ -427,6 +435,7 @@ private function run(): void $method === 'GET' && $action === 'image' && !empty($this->o['imageSources']) => $this->handleImage(), $method === 'POST' && $action === 'link' && $this->identity !== null => $this->handleLink(), $method === 'POST' && $action === 'job-access' && $this->identity !== null => $this->handleJobAccess(), + $method === 'POST' && in_array($action, ['credential-request', 'credential-request-handoff', 'credential-request-cancel'], true) => $this->handleCredentialRequest($action), default => $this->fail(404, 'Not found', 'not_found'), }; } catch (DeweeRateLimitUnavailable) { @@ -935,6 +944,38 @@ private function handleJobAccess(): void catch (Throwable) { $this->fail(502, 'Job access is unavailable. Try again later.', 'job_access_unavailable'); } } + private function handleCredentialRequest(string $route): void + { + if ($this->ccp === null || $this->identity === null || $this->credentialFormOrigin === null) { $this->fail(404, 'Not found', 'not_found'); return; } + $origin = self::header('origin') ?? ''; + $parsed = parse_url($origin); + if (!is_array($parsed) || ($parsed['scheme'] ?? '') !== 'https' || empty($parsed['host']) || isset($parsed['user']) || isset($parsed['pass']) || isset($parsed['path']) || isset($parsed['query']) || isset($parsed['fragment']) || (isset($parsed['port']) && $parsed['port'] === 443) || $origin !== 'https://' . strtolower($parsed['host']) . (isset($parsed['port']) ? ':' . $parsed['port'] : '')) { $this->fail(403, 'Origin not allowed', 'forbidden_origin'); return; } + $claims = $this->claimsFromRequest(); + if (!isset($claims['ids'], $claims['ida'])) { $this->identityFail('IDENTITY_REQUIRED'); return; } + if (!$this->identityRebind($claims, true)) return; + if (!$this->rateLimit('credential:' . $claims['v'], $route === 'credential-request-handoff' ? 'link' : 'preauth')) { $this->fail(429, 'Too many requests', 'rate_limited'); return; } + $raw = file_get_contents('php://input', false, null, 0, 8193); + $body = is_string($raw) && strlen($raw) <= 8192 ? json_decode($raw, true) : null; + if (!is_array($body) || array_diff(array_keys($body), ['agent', 'conversationId', 'credentialRequestId']) || !is_string($body['conversationId'] ?? null) || !preg_match(self::CCP_ID_RE, $body['conversationId']) || !is_string($body['credentialRequestId'] ?? null) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD', $body['credentialRequestId'])) { $this->fail(400, 'Invalid request', 'bad_request'); return; } + $asked = self::ccpAgentFor($body['agent'] ?? null); + if ($asked === null) { $this->fail(400, 'Invalid agent'); return; } + if ($asked !== '' && $asked !== $claims['ida']) { $this->fail(403, 'Agent not allowed', 'agent_not_allowed'); return; } + $agent = $claims['ida']; + try { + $ctx = $this->visitorContext(); + $input = ['conversation_id' => $body['conversationId'], 'credential_request_id' => $body['credentialRequestId'], 'identity_session' => $claims['ids'], 'agent' => $agent]; + $result = $this->withEdge($ctx, $claims['v'], self::sealedEdge($claims), fn (array $edge) => $this->ccp->json($route, $ctx + ['body' => $input, 'session' => $edge['token'], 'credentialRequests' => true])); + if ($route === 'credential-request-handoff') { + $handoff = self::parseCredentialHandoff($result, $body['credentialRequestId'], $this->credentialFormOrigin); + if ($handoff === null) { $this->fail(502, 'Credential request is unavailable', 'credential_request_unavailable'); return; } + $this->json($handoff); return; + } + $view = self::parseCredentialRequest($result['credential_request'] ?? null, $agent); + if ($view === null || $view['id'] !== $body['credentialRequestId']) { $this->fail(502, 'Credential request is unavailable', 'credential_request_unavailable'); return; } + $this->json(['credentialRequest' => $view]); + } catch (DeweeCcpError $e) { $this->ccpFail($e, $route); } + } + private function handleLink(): void { $claims = $this->claimsFromRequest(); @@ -1025,7 +1066,14 @@ private function handleChat(): void $body = is_string($raw) ? json_decode($raw, true) : null; if (!$rawBody instanceof stdClass || !is_array($body)) { $this->fail(400, 'Invalid request', 'bad_request'); return; } $message = is_string($body['message'] ?? null) ? self::jsTrim($body['message']) : ''; - if ($message === '') { + $resume = $body['credentialRequestId'] ?? null; + if (array_key_exists('credentialRequestId', $body) && $resume === null) { $this->fail(400, 'Invalid request', 'bad_request'); return; } + if ($resume !== null) { + $origin = self::header('origin') ?? ''; $parsed = parse_url($origin); + if ($this->ccp === null || $this->credentialFormOrigin === null || !isset($claims['ids']) || !is_string($resume) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD', $resume) || (array_key_exists('message', $body) && !is_string($body['message'])) || $message !== '' || isset($body['actions']) || isset($body['pageContext']) || isset($body['page_context']) || !is_string($body['conversationId'] ?? null) || !preg_match(self::CCP_ID_RE, $body['conversationId'])) { $this->fail(400, 'Invalid request', 'bad_request'); return; } + if (!is_array($parsed) || ($parsed['scheme'] ?? '') !== 'https' || empty($parsed['host']) || isset($parsed['user']) || isset($parsed['pass']) || isset($parsed['path']) || isset($parsed['query']) || isset($parsed['fragment']) || (isset($parsed['port']) && $parsed['port'] === 443) || $origin !== 'https://' . strtolower($parsed['host']) . (isset($parsed['port']) ? ':' . $parsed['port'] : '')) { $this->fail(403, 'Origin not allowed', 'forbidden_origin'); return; } + if (!$this->identityRebind($claims, true)) return; + } elseif ($message === '') { $this->fail(400, 'Message is required'); return; } @@ -1074,7 +1122,7 @@ private function handleChat(): void if (!self::validPageContext($context)) { $this->fail(400, 'Invalid page context', 'page_context_invalid'); return; } } if ($this->ccp !== null) { - $this->ccpChat($claims, $pageBlock . $message, $message, $conversationId, $agent, $actionsHash, $context); + $this->ccpChat($claims, $pageBlock . $message, $message, $conversationId, $agent, $actionsHash, $context, $resume); return; } if ($creds === null) { @@ -1256,6 +1304,7 @@ private function handleCcpConfig(): void 'refreshWithoutTurnstile' => !empty($this->o['turnstile']) ? true : null, 'maxMessageLength' => min((int) $this->o['maxMessageLength'], $cfg['maxMessageChars']), 'pageContextVersions' => !empty($cfg['pageContextVersions']) ? $cfg['pageContextVersions'] : null, + 'credentialFormOrigin' => $this->credentialFormOrigin, 'version' => self::VERSION, ])); } @@ -1278,7 +1327,7 @@ private static function ccpAgentFor(mixed $requested): ?string * * @param array{v: string, es?: string, ese?: int|float} $claims */ - private function ccpChat(array $claims, string $message, string $rawMessage, string $conversationId, string $agent, ?string $actionsHash, mixed $context): void + private function ccpChat(array $claims, string $message, string $rawMessage, string $conversationId, string $agent, ?string $actionsHash, mixed $context, ?string $resume = null): void { $visitorId = $claims['v']; $ctx = $this->visitorContext(); @@ -1297,19 +1346,19 @@ private function ccpChat(array $claims, string $message, string $rawMessage, str $this->fail(413, "Message exceeds {$cfg['maxMessageChars']} characters", 'too_long'); return; } - $body = ['message' => $message, 'conversation_id' => $conversationId] + ($agent !== '' ? ['agent' => $agent] : []) + (isset($claims['ids']) ? ['identity_session' => $claims['ids']] : []) + ($context !== null ? ['page_context' => $context] : []); + $body = ['message' => $message, 'conversation_id' => $conversationId] + ($agent !== '' ? ['agent' => $agent] : []) + (isset($claims['ids']) ? ['identity_session' => $claims['ids']] : []) + ($context !== null ? ['page_context' => $context] : []) + ($resume !== null ? ['credential_request_id' => $resume] : []); $edge = null; try { - $fp = $this->withEdge($ctx, $visitorId, self::sealedEdge($claims), function (array $e) use ($ctx, $body, &$edge) { + $fp = $this->withEdge($ctx, $visitorId, self::sealedEdge($claims), function (array $e) use ($ctx, $body, $claims, &$edge) { $edge = $e; - return $this->ccp->open('chat', $ctx + ['body' => $body, 'session' => $e['token'], 'accept' => 'text/event-stream']); + return $this->ccp->open('chat', $ctx + ['body' => $body, 'session' => $e['token'], 'accept' => 'text/event-stream', 'credentialRequests' => $this->credentialFormOrigin !== null && isset($claims['ids'])]); }); } catch (DeweeCcpError $e) { $this->ccpFail($e, 'chat'); return; } - $this->beginEventStream(); + $this->beginEventStream(true); $lastWrite = microtime(true); $aborted = false; $write = static function (string $chunk) use (&$lastWrite, &$aborted): bool { @@ -1366,7 +1415,7 @@ private function ccpChat(array $claims, string $message, string $rawMessage, str if (!$started) { $start(null); } - $ev = self::ccpStreamEvent($f, $cfg); + $ev = self::ccpStreamEvent($f, $cfg, $this->credentialFormOrigin !== null && isset($claims['ids']) ? ($agent !== '' ? $agent : $cfg['defaultAgent']) : null); if ($ev === null) { continue; } @@ -1427,11 +1476,14 @@ private function ccpChat(array $claims, string $message, string $rawMessage, str * @param array{showThinking: bool, showToolActivity: bool} $cfg * @return array|null */ - public static function ccpStreamEvent(array $f, array $cfg): ?array + public static function ccpStreamEvent(array $f, array $cfg, ?string $credentialAgent = null): ?array { $id = is_string($f['id'] ?? null) ? substr($f['id'], 0, 128) : null; $name = is_string($f['name'] ?? null) ? substr($f['name'], 0, 128) : null; switch ($f['type'] ?? null) { + case 'credential_request': + $view = $credentialAgent !== null ? self::parseCredentialRequest($f['credential_request'] ?? null, $credentialAgent) : null; + return $view !== null ? ['type' => 'credential_request', 'credentialRequest' => $view] : null; case 'delta': return is_string($f['text'] ?? null) ? ['type' => 'delta', 'text' => $f['text']] : null; case 'thinking': @@ -1519,7 +1571,7 @@ private function ccpHistory(array $claims): void $body = ['conversation_id' => $conversationId, 'limit' => 50] + ($agent !== '' ? ['agent' => $agent] : []) + (isset($claims['ids']) ? ['identity_session' => $claims['ids']] : []); try { $cfg = $this->ccpConfig(); - $data = $this->withEdge($ctx, $claims['v'], self::sealedEdge($claims), fn (array $e) => $this->ccp->json('history', $ctx + ['body' => $body, 'session' => $e['token']])); + $data = $this->withEdge($ctx, $claims['v'], self::sealedEdge($claims), fn (array $e) => $this->ccp->json('history', $ctx + ['body' => $body, 'session' => $e['token'], 'credentialRequests' => $this->credentialFormOrigin !== null && isset($claims['ids'])])); } catch (DeweeCcpError $e) { if ($e->ccpCode === 'not_found') { $this->json(['messages' => []]); @@ -1528,7 +1580,16 @@ private function ccpHistory(array $claims): void $this->ccpFail($e, 'history'); return; } - $this->json(['messages' => self::ccpHistoryMessages(is_array($data['messages'] ?? null) ? $data['messages'] : [], $cfg)]); + $out = ['messages' => self::ccpHistoryMessages(is_array($data['messages'] ?? null) ? $data['messages'] : [], $cfg)]; + if ($this->credentialFormOrigin !== null && isset($claims['ids']) && is_array($data['credential_requests'] ?? null)) { + $cards = []; + foreach (array_slice($data['credential_requests'], 0, 100) as $raw) { + $view = self::parseCredentialRequest($raw, $agent !== '' ? $agent : $cfg['defaultAgent']); + if ($view !== null) $cards[] = $view; + } + if ($cards) $out['credentialRequests'] = $cards; + } + $this->json($out); } /** @@ -1695,6 +1756,31 @@ public static function parseCcpConfig(array $data): array ]; } + /** Rebuild only value-free fields of a typed, actor-bound request. */ + public static function parseCredentialRequest(mixed $raw, string $agent): ?array + { + if (!is_array($raw) || !is_string($raw['id'] ?? null) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD', $raw['id']) || ($raw['schema_version'] ?? null) !== 'webchat-credential-request.v1' || !in_array($raw['status'] ?? null, ['pending','applying','resolved','cancelled','expired','failed'], true) || !is_array($raw['target'] ?? null) || !is_array($raw['fields'] ?? null) || !array_is_list($raw['fields']) || count($raw['fields']) > 32 || ($raw['agent_key'] ?? null) !== $agent || ($raw['completion'] ?? null) !== 'hosted' || !is_bool($raw['resumable'] ?? null)) return null; + foreach (['title' => 300, 'purpose' => 4000, 'expires_at' => 64] as $key => $max) if (!is_string($raw[$key] ?? null) || self::utf16Length($raw[$key]) > $max) return null; + if (strtotime($raw['expires_at']) === false) return null; + foreach (['type', 'mode'] as $key) if (!is_string($raw['target'][$key] ?? null) || self::utf16Length($raw['target'][$key]) > 100) return null; + $fields = []; + foreach ($raw['fields'] as $field) { + if (!is_array($field) || !is_string($field['key'] ?? null) || !preg_match('/^[A-Za-z0-9_.-]{1,100}$/D', $field['key']) || !is_string($field['label'] ?? null) || self::utf16Length($field['label']) > 300 || !is_string($field['kind'] ?? null) || self::utf16Length($field['kind']) > 100 || !is_bool($field['required'] ?? null)) return null; + $fields[] = ['key' => $field['key'], 'label' => $field['label'], 'kind' => $field['kind'], 'required' => $field['required']]; + } + return ['id' => $raw['id'], 'schema_version' => $raw['schema_version'], 'status' => $raw['status'], 'title' => $raw['title'], 'purpose' => $raw['purpose'], 'target' => ['type' => $raw['target']['type'], 'mode' => $raw['target']['mode']], 'fields' => $fields, 'expires_at' => $raw['expires_at'], 'agent_key' => $agent, 'completion' => 'hosted', 'resumable' => $raw['resumable']]; + } + + public static function parseCredentialHandoff(mixed $raw, string $id, string $origin): ?array + { + if (!is_array($raw) || ($raw['credential_request_id'] ?? null) !== $id || !is_string($raw['url'] ?? null) || strlen($raw['url']) > 4096 || preg_match('/[\x00-\x20\x7f\\\\]/', $raw['url']) || !is_string($raw['expires_at'] ?? null) || strlen($raw['expires_at']) > 64 || ($expires = strtotime($raw['expires_at'])) === false || $expires <= time()) return null; + $u = parse_url($raw['url']); + if (!is_array($u) || strtolower($u['scheme'] ?? '') !== 'https' || empty($u['host']) || isset($u['user']) || isset($u['pass']) || isset($u['fragment']) || ($u['path'] ?? '') !== '/credential-requests/open' || !is_string($u['query'] ?? null) || !preg_match('/^token=[A-Za-z0-9_-]{43}$/D', $u['query'])) return null; + $actual = 'https://' . strtolower($u['host']) . (isset($u['port']) && $u['port'] !== 443 ? ':' . $u['port'] : ''); + if ($actual !== $origin) return null; + return ['credential_request_id' => $id, 'url' => $raw['url'], 'expires_at' => $raw['expires_at']]; + } + /** Validate the v1 attachment without logging or altering its content. */ private static function validPageContext(mixed $value): bool { @@ -1853,6 +1939,13 @@ public static function ccpBrowserError(Throwable $e, string $route): array } $limited = ['status' => 429, 'code' => 'rate_limited', 'message' => 'Too many messages, please wait a moment'] + ($e->retryAfterSeconds ? ['retryAfterSeconds' => $e->retryAfterSeconds] : []); switch ($e->ccpCode) { + case 'credential_requests_unavailable': + return ['status' => 403, 'code' => $e->ccpCode, 'message' => 'Credential requests are unavailable']; + case 'credential_request_not_found': + return ['status' => 404, 'code' => $e->ccpCode, 'message' => 'Credential request not found']; + case 'credential_request_not_ready': + case 'credential_request_resumed': + return ['status' => 409, 'code' => $e->ccpCode, 'message' => 'Credential request cannot be resumed']; case 'idempotency_key_reused': return ['status' => 409, 'code' => 'idempotency_key_reused', 'message' => 'This idempotency key was already used for a different link request']; case 'link_in_progress': @@ -2579,7 +2672,7 @@ private function fail(int $status, string $message, string $code = 'bad_request' $this->json(['error' => ['message' => $message, 'code' => $code]], $status); } - private function beginEventStream(): void + private function beginEventStream(bool $noStore = false): void { @set_time_limit(0); ignore_user_abort(true); // keep running after a disconnect so the gateway run can be aborted @@ -2595,7 +2688,7 @@ private function beginEventStream(): void http_response_code(200); $this->sendHeaders([ 'Content-Type' => 'text/event-stream; charset=utf-8', - 'Cache-Control' => 'no-cache, no-transform', + 'Cache-Control' => $noStore ? 'no-store, no-transform' : 'no-cache, no-transform', 'X-Accel-Buffering' => 'no', ] + $this->corsHeaders()); @flush(); @@ -2856,7 +2949,7 @@ public static function clip(mixed $v, int $max = 4000): ?string */ final class DeweeCcpClient { - public const ROUTES = ['config', 'session', 'chat', 'history', 'abort', 'identity/exchange', 'identity/revoke', 'link', 'job-access']; + public const ROUTES = ['config', 'session', 'chat', 'history', 'abort', 'identity/exchange', 'identity/revoke', 'link', 'job-access', 'credential-request', 'credential-request-handoff', 'credential-request-cancel']; private const INTEGRATION_ID_RE = '/^wci_[a-z2-7]{26}$/'; private const SECRET_RE = '/^dwc_[A-Za-z0-9_-]{16,256}$/'; private const INGRESS_PATH_RE = '#/api/public/webchat/v1/(wci_[a-z2-7]{26})$#'; @@ -2944,6 +3037,7 @@ public function open(string $route, array $req = []) 'User-Agent: dewee-webchat-php/' . DeweeWebchat::VERSION, 'Connection: close', ]; + if (($req['credentialRequests'] ?? false) === true) $headers[] = 'X-Dewee-Webchat-Features: credential-requests'; $body = null; if (isset($req['body'])) { $body = (string) json_encode((object) $req['body'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS | JSON_INVALID_UTF8_SUBSTITUTE); @@ -3216,6 +3310,8 @@ public static function streamChat(array $creds, string $userId, string $agent, s { $conn = $connection ?? self::connect($creds, $userId); $aborted = false; + $toolNames = []; + $sensitiveToolIds = []; try { $id = $conn->send('chat.send', ['agentId' => $agent, 'sessionKey' => $sessionKey, 'message' => $message, 'stream' => true] + ($pageContext !== null ? ['pageContext' => $pageContext] : [])); $deadline = microtime(true) + $timeoutMs / 1000; @@ -3256,6 +3352,21 @@ public static function streamChat(array $creds, string $userId, string $agent, s continue; } [$event, $end] = self::mapAgentEvent($frame->payload, $sessionKey); + if (is_array($event) && in_array($event['type'] ?? null, ['tool_call', 'tool_result'], true)) { + $raw = $frame->payload->payload ?? null; + $name = is_object($raw) && is_string($raw->name ?? null) ? $raw->name : ''; + $toolId = $event['id'] ?? ''; + if ($event['type'] === 'tool_call') { + if ($toolId !== '' && self::credentialTool($name)) $sensitiveToolIds[$toolId] = true; + if ($toolId !== '' && $name !== '') $toolNames[$toolId] = $name; + if ($name === '' || isset($sensitiveToolIds[$toolId]) || self::credentialTool($name)) $event = null; + } else { + $known = $toolNames[$toolId] ?? ''; + $name = $name !== '' ? $name : $known; + if ($toolId === '' || $known === '' || $name !== $known || isset($sensitiveToolIds[$toolId]) || self::credentialTool($name)) $event = null; + else $event['name'] = $name; + } + } if ($event !== null && !$emit($event)) { $aborted = true; break; @@ -3316,6 +3427,11 @@ private static function mapAgentEvent(object $payload, string $sessionKey): arra }; } + private static function credentialTool(mixed $name): bool + { + return is_string($name) && preg_match('/(?:^|__)request_credentials$/iD', $name) === 1; + } + /** * Fetches a session transcript and folds tool calls/results into assistant turns. * @@ -3343,6 +3459,14 @@ public static function fetchHistory(array $creds, string $userId, string $agent, public static function foldHistory(array $raw): array { $out = []; + $sensitiveIds = []; + foreach ($raw as $message) { + $calls = self::prop($message, 'tool_calls'); + foreach (is_array($calls) ? $calls : [] as $call) { + $fn = self::prop($call, 'function'); $name = self::prop($call, 'name') ?? self::prop($fn, 'name'); $id = self::prop($call, 'id'); + if (self::credentialTool($name) && is_string($id)) $sensitiveIds[$id] = true; + } + } $cur = null; // index into $out of the open assistant turn foreach ($raw as $m) { $role = self::prop($m, 'role'); @@ -3365,16 +3489,20 @@ public static function foldHistory(array $raw): array $toolCalls = self::prop($m, 'tool_calls'); foreach (is_array($toolCalls) ? $toolCalls : [] as $tc) { $fn = self::prop($tc, 'function'); + $name = self::prop($tc, 'name') ?? self::prop($fn, 'name'); + $toolId = self::prop($tc, 'id'); + if (!is_string($toolId) || $toolId === '' || !is_string($name) || $name === '' || isset($sensitiveIds[$toolId]) || self::credentialTool($name)) continue; $out[$cur]['toolCalls'] ??= []; $out[$cur]['toolCalls'][] = DeweeWebchat::withoutNulls([ 'id' => self::prop($tc, 'id'), - 'name' => self::prop($tc, 'name') ?? self::prop($fn, 'name') ?? 'tool', + 'name' => $name, 'arguments' => self::prop($tc, 'arguments') ?? self::prop($fn, 'arguments'), 'status' => 'done', ]); } } elseif ($role === 'tool' && $cur !== null && isset($out[$cur]['toolCalls'])) { $callId = self::prop($m, 'tool_call_id'); + if (!is_string($callId) || $callId === '' || isset($sensitiveIds[$callId])) continue; foreach ($out[$cur]['toolCalls'] as $i => $tc) { if (($tc['id'] ?? null) === $callId) { $result = DeweeWebchat::clip(self::prop($m, 'content'));