diff --git a/tests/runner-e2e/chat-catalog-domains/external-assistant-device.ts b/tests/runner-e2e/chat-catalog-domains/external-assistant-device.ts index b4dbabce721..54b2482a98f 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-assistant-device.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-assistant-device.ts @@ -1,13 +1,15 @@ -import { expect } from "@playwright/test"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; import { pollUntil } from "../api.js"; import { definition, externalObservation, observeExternal, checks, verifyRenderedReply } from "./external-support.js"; import { waitForReply } from "./identity-support.js"; import { oauthPost, mcp, origin, resource, type Tokens } from "../public-mcp-client.js"; +import { privateBrowserEntry } from "./external-private-browser.js"; +import { matchesDevicePrivateReceipt } from "./external-device-private-contract.js"; + const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; interface DeviceCode { device_code: string; user_code: string; verification_uri: string; verification_uri_complete: string; expires_in: number; interval: number } -interface DeviceFixture { clientId: string; otherClientId: string; primary: DeviceCode; expiring: DeviceCode; expiresAt: number; polledAt: number; tokens?: Tokens } +interface DeviceFixture { personId: string; clientId: string; otherClientId: string; primary: DeviceCode; expiring: DeviceCode; expiresAt: number; polledAt: number; tokens?: Tokens } const fixtures = new WeakMap(); async function requestDevice(clientId: string, companyId: string): Promise { const response = await oauthPost("device_authorization", { client_id: clientId, resource, company_id: companyId, scope: "paperclip:read offline_access" }); @@ -28,6 +30,8 @@ export function gradeDeviceLogin(value: unknown) { c.add("correct-human-verification", o.state.verificationPath === "/mcp-device" && o.state.currentCodeVisible === true && o.state.companyConsentVisible === true && o.state.readScopeVisible === true && o.state.humanApproved === true && o.state.clientCompanyId === o.companyId && o.state.clientReadOnly === true, "The person follows the issuer's actual page/current code and authorizes exactly the intended company and read scope."); + c.add("private-device-ui", matchesDevicePrivateReceipt("device-approve", o.state.approvalUi) && matchesDevicePrivateReceipt("device-expired", o.state.expiredUi), + "Both actual device UI phases use the isolated finite browser with closed receipts, outside Test reporting."); c.add("pending-without-human-approval", o.state.pendingError === "authorization_pending", "The waiting CLI cannot obtain credentials before human approval."); c.add("resource-and-client-mismatch-denied", o.state.wrongResourceDenied === true && o.state.wrongClientDenied === true && o.state.correctExchangeSucceeded === true, "Mismatched resource/client requests fail without consuming the valid selected grant."); @@ -44,25 +48,33 @@ export const assistantDeviceExternalCases = [definition("assistant-connections.d const setup = await ctx.input.api.get<{ enabled: boolean; serverUrl: string }>("/api/mcp/setup"); if (!setup.enabled || setup.serverUrl !== resource) throw new Error("Setup blocker: this disposable instance has no compatible public MCP issuer/resource"); const clientId = await registerClient(), otherClientId = await registerClient(); - const primary = await requestDevice(clientId, ctx.input.fixtures.company.id), expiring = await requestDevice(clientId, ctx.input.fixtures.company.id); + const primary = await requestDevice(clientId, ctx.input.fixtures.company.id); + ctx.input.privateSecrets.push(primary.device_code, primary.user_code, primary.verification_uri_complete); + const expiring = await requestDevice(clientId, ctx.input.fixtures.company.id); + ctx.input.privateSecrets.push(expiring.device_code, expiring.user_code, expiring.verification_uri_complete); if (primary.expires_in !== 600 || expiring.expires_in !== 600 || primary.verification_uri !== `${origin}/mcp-device`) throw new Error("Declared bounded device-code lifetime or verification origin differs from the real issuer"); - ctx.input.privateSecrets.push(primary.device_code, primary.user_code, expiring.device_code, expiring.user_code); + const session = await ctx.input.api.get<{ user: { id: string } }>("/api/auth/get-session"); + if (!session.user?.id) throw new Error("Device UI requires the authenticated fixture person"); const expiresAt = Date.now() + expiring.expires_in * 1000; - const fixture = { clientId, otherClientId, primary, expiring, expiresAt, polledAt: Date.now() }; fixtures.set(ctx, fixture); + const fixture = { personId: session.user.id, clientId, otherClientId, primary, expiring, expiresAt, polledAt: Date.now() }; fixtures.set(ctx, fixture); const pending = await exchange(fixture, primary); const pendingBody = await pending.json() as { error?: string }; return { verificationPath: new URL(primary.verification_uri).pathname, pendingError: pendingBody.error, expirationWaitMs: 600_000 }; }, gradeDeviceLogin, { authentication: "human-session", attemptTimeoutMs: 780_000, - sourceFiles: ["chat-catalog-domains/external-assistant-device.ts", "chat-catalog-domains/external-support.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/identity-support.ts", "public-mcp-client.ts", "chat-scenarios/assistant-connections.yaml"], + sourceFiles: ["chat-catalog-domains/external-assistant-device.ts", "chat-catalog-domains/external-private-browser.ts", "chat-catalog-domains/external-private-browser-worker.ts", "chat-catalog-domains/external-device-private-contract.ts", "../../ui/src/pages/McpConnect.tsx", "../../server/src/routes/public-mcp.ts", "../../server/src/services/public-mcp/oauth.ts", "../../packages/shared/src/public-mcp.ts", "chat-catalog-domains/external-support.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/identity-support.ts", "public-mcp-client.ts", "chat-scenarios/assistant-connections.yaml"], async act(ctx, state) { await waitForReply(ctx); const fixture = fixtures.get(ctx); if (!fixture) throw new Error("Device fixture missing"); - const source = ctx.input.page.url(); await ctx.input.page.goto(fixture.primary.verification_uri_complete); - await expect(ctx.input.page.getByRole("heading", { name: /^Connect .+ to Paperclip$/ })).toBeVisible(); - await expect(ctx.input.page.getByText(fixture.primary.user_code.toUpperCase(), { exact: true })).toBeVisible(); state.currentCodeVisible = true; - await expect(ctx.input.page.getByText(ctx.input.fixtures.company.name, { exact: true })).toBeVisible(); state.companyConsentVisible = true; - await expect(ctx.input.page.getByText("Read all of your Paperclip data", { exact: true })).toBeVisible(); state.readScopeVisible = true; - await ctx.input.page.getByRole("button", { name: "Connect organization", exact: true }).click(); - await expect(ctx.input.page.getByText(/connected|approved/i).first()).toBeVisible(); state.humanApproved = true; + const approvalUi = await privateBrowserEntry({ + mode: "device-approve", baseURL: origin, setupURL: fixture.primary.verification_uri_complete, + providerURL: origin, credential: fixture.primary.user_code, companyId: ctx.input.fixtures.company.id, + device: { userId: fixture.personId, companyName: ctx.input.fixtures.company.name }, + cookies: await ctx.input.page.context().cookies(origin), + }); + state.approvalUi = approvalUi; + state.currentCodeVisible = approvalUi.currentCodeVisible; + state.companyConsentVisible = approvalUi.companyConsentVisible; + state.readScopeVisible = approvalUi.readScopeVisible; + state.humanApproved = approvalUi.humanApproved; const wrongResource = await exchange(fixture, fixture.primary, { resource: origin + "/mcp/not-the-resource" }); state.wrongResourceDenied = wrongResource.status === 400; await wrongResource.body?.cancel(); const wrongClient = await exchange(fixture, fixture.primary, { client_id: fixture.otherClientId }); state.wrongClientDenied = wrongClient.status === 400; await wrongClient.body?.cancel(); if (Date.now() < fixture.polledAt + fixture.primary.interval * 1000) await pollUntil({ label: "advertised device polling interval", deadlineAt: Date.now() + 15_000, intervalMs: 200, load: async () => Date.now(), accept: now => now >= fixture.polledAt + fixture.primary.interval * 1000 }); @@ -81,9 +93,14 @@ export const assistantDeviceExternalCases = [definition("assistant-connections.d state.elapsedBeforeExpiryProbe = Date.now() - (fixture.expiresAt - 600_000); const expired = await exchange(fixture, fixture.expiring); const body = await expired.json() as { error?: string }; state.expiredCodeDenied = expired.status === 400 && body.error === "expired_token"; - await ctx.input.page.goto(fixture.expiring.verification_uri_complete); - await expect(ctx.input.page.getByText(/expired|invalid/i).first()).toBeVisible(); state.expiredPageVisible = true; - await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); + const expiredUi = await privateBrowserEntry({ + mode: "device-expired", baseURL: origin, setupURL: fixture.expiring.verification_uri_complete, + providerURL: origin, credential: fixture.expiring.user_code, companyId: ctx.input.fixtures.company.id, + device: { userId: fixture.personId, companyName: ctx.input.fixtures.company.name }, + cookies: await ctx.input.page.context().cookies(origin), + }); + state.expiredUi = expiredUi; + state.expiredPageVisible = expiredUi.expiredPageVisible; }, async observe(ctx, state) { return observeExternal(ctx, state); }, async verifyResult(ctx, _state, value) { await verifyRenderedReply(ctx, value); }, diff --git a/tests/runner-e2e/chat-catalog-domains/external-device-private-contract.test.ts b/tests/runner-e2e/chat-catalog-domains/external-device-private-contract.test.ts new file mode 100644 index 00000000000..40924063d57 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-device-private-contract.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; +import { matchesDevicePrivateReceipt, validDevicePrivateInput } from "./external-device-private-contract.js"; + +const base = "http://127.0.0.1:3100"; +const code = "ABCD-EFGH"; // Synthetic calibration only, never a provider-issued code. +const url = `${base}/mcp-device?user_code=${code}`; +const company = "11111111-1111-4111-8111-111111111111"; +const device = { userId: "fixture-person", companyName: "Owned company" }; + +it("allows only the exact owned device page and current code for two finite phases", () => { + for (const mode of ["device-approve", "device-expired"]) { + expect(validDevicePrivateInput(mode, base, url, company, device, code)).toBe(true); + for (const target of [ + url.replace("127.0.0.1", "example.test"), url.replace("http:", "https:"), + url.replace("/mcp-device", "/mcp-connect"), url + "&next=/other", url + "#secret", + url.replace(code, "WXYZ-1234"), url + `&user_code=${code}`, + url.replace("http://", "http://user:password@"), + ]) expect(validDevicePrivateInput(mode, base, target, company, device, code)).toBe(false); + } + expect(validDevicePrivateInput("arbitrary", base, url, company, device, code)).toBe(false); + expect(validDevicePrivateInput("device-approve", base, url, company, undefined, code)).toBe(false); + expect(validDevicePrivateInput("device-approve", base, url, "wrong", device, code)).toBe(false); + expect(validDevicePrivateInput("device-approve", base, url, company, { ...device, userId: "" }, code)).toBe(false); + expect(validDevicePrivateInput("device-approve", base, url, company, device, "bad\r\ncode")).toBe(false); +}); + +it("accepts only complete boolean-only private receipts, never codes, URLs or forged capture claims", () => { + const boundary = { processIsolation: true, testRunnerAttached: false, tracingEnabled: false, videoEnabled: false, apiStepRecorder: false, deviceSessionVerified: true }; + const approval = { ...boundary, currentCodeVisible: true, companyConsentVisible: true, readScopeVisible: true, humanApproved: true }; + const expired = { ...boundary, expiredPageVisible: true }; + expect(matchesDevicePrivateReceipt("device-approve", approval)).toBe(true); + expect(matchesDevicePrivateReceipt("device-expired", expired)).toBe(true); + for (const wrong of [undefined, {}, expired, { ...approval, tracingEnabled: true }, { ...approval, deviceSessionVerified: false }, { ...approval, currentCodeVisible: false }, { ...approval, userCode: code }, { ...approval, verificationUri: url }, { ...approval, humanApproved: "true" }]) + expect(matchesDevicePrivateReceipt("device-approve", wrong)).toBe(false); + expect(matchesDevicePrivateReceipt("device-expired", { ...expired, expiredPageVisible: false })).toBe(false); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-device-private-contract.ts b/tests/runner-e2e/chat-catalog-domains/external-device-private-contract.ts new file mode 100644 index 00000000000..2b4d81a9096 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-device-private-contract.ts @@ -0,0 +1,46 @@ +export interface DevicePrivateInput { + userId: string; + companyName: string; +} + +/** Only the owned issuer's exact device-code UI is allowed, never arbitrary + * navigation. The current code and complete URL remain private worker input. */ +export function validDevicePrivateInput( + mode: string, + baseURL: string, + setupURL: string, + companyId: unknown, + device: DevicePrivateInput | undefined, + userCode: string, +) { + try { + const base = new URL(baseURL), setup = new URL(setupURL); + return ["device-approve", "device-expired"].includes(mode) + && base.protocol === "http:" && base.hostname === "127.0.0.1" + && !base.username && !base.password && base.pathname === "/" && !base.search && !base.hash + && setup.origin === base.origin && !setup.username && !setup.password + && setup.pathname === "/mcp-device" && !setup.hash + && setup.searchParams.size === 1 && setup.searchParams.get("user_code") === userCode + && /^[A-Za-z0-9 -]{8,12}$/.test(userCode) + && typeof companyId === "string" && /^[a-f0-9-]{36}$/i.test(companyId) + && !!device && typeof device.userId === "string" && device.userId.length > 0 && device.userId.length <= 256 + && typeof device.companyName === "string" && device.companyName.trim().length > 0 && device.companyName.length <= 256 + && !/[\r\n]/.test(device.userId + device.companyName); + } catch { return false; } +} + +export function matchesDevicePrivateReceipt(mode: string, value: unknown) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const receipt = value as Record; + const expected = { + processIsolation: true, testRunnerAttached: false, tracingEnabled: false, + videoEnabled: false, apiStepRecorder: false, deviceSessionVerified: true, + ...(mode === "device-approve" ? { + currentCodeVisible: true, companyConsentVisible: true, readScopeVisible: true, + humanApproved: true, + } : mode === "device-expired" ? { expiredPageVisible: true } : {}), + }; + return ["device-approve", "device-expired"].includes(mode) + && Object.keys(receipt).length === Object.keys(expected).length + && Object.entries(expected).every(([key, expectedValue]) => receipt[key] === expectedValue); +} diff --git a/tests/runner-e2e/chat-catalog-domains/external-private-browser-worker.ts b/tests/runner-e2e/chat-catalog-domains/external-private-browser-worker.ts index 4e90ba4f27a..b414c42a878 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-private-browser-worker.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-private-browser-worker.ts @@ -1,23 +1,57 @@ +import { validDevicePrivateInput, type DevicePrivateInput } from "./external-device-private-contract.js"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { chromium, test, type Cookie } from "@playwright/test"; import { validGrokRoutinePrivateInput, matchesConfiguredGrokRoutine, type GrokRoutinePrivateInput } from "./external-grok-private-contract.js"; import { validSlackIdentityInput, validSlackWizardIdentityInput, matchingSlackIdentityPreview, type SlackIdentityPrivateInput } from "./external-slack-identity-contract.js"; import { validSlackSetupPrivate, type SlackSetupPrivateInput } from "./external-slack-setup-private-contract.js"; -interface Request { mode: "secure-key" | "canary" | "gateway-token-rotation" | "company-secret-create" | "personal-secret-create" | "grok-routine-configure" | "slack-identity-confirm" | "slack-endpoint-configure" | "slack-wizard-confirm" | "slack-wizard-resume" | "slack-endpoint-reconnect"; slackSetup?:SlackSetupPrivateInput; slackIdentity?:SlackIdentityPrivateInput; grok?:GrokRoutinePrivateInput; companyId?:string; personal?:{definitionId:string;userId:string}; gateway?: { id: string; oldTokenId: string; otherToken: string; endpointPath: string; readName: string }; baseURL: string; setupURL: string; providerURL: string; credential: string; cookies: Cookie[]; executablePath?: string; channel?: string } +interface Request { mode: "secure-key" | "canary" | "gateway-token-rotation" | "company-secret-create" | "personal-secret-create" | "grok-routine-configure" | "slack-identity-confirm" | "slack-endpoint-configure" | "slack-wizard-confirm" | "slack-wizard-resume" | "slack-endpoint-reconnect" | "device-approve" | "device-expired"; device?: DevicePrivateInput; slackSetup?:SlackSetupPrivateInput; slackIdentity?:SlackIdentityPrivateInput; grok?:GrokRoutinePrivateInput; companyId?:string; personal?:{definitionId:string;userId:string}; gateway?: { id: string; oldTokenId: string; otherToken: string; endpointPath: string; readName: string }; baseURL: string; setupURL: string; providerURL: string; credential: string; cookies: Cookie[]; executablePath?: string; channel?: string } /** Separate Node process: no Playwright Test worker, API-step listener or artifact * recorder exists. This module never enables tracing, video or screenshots. */ async function main() { let attached = false; try { test.info(); attached = true; } catch { /* expected outside Test */ } if (attached) throw new Error("Private browser worker cannot run inside Playwright Test"); const input = JSON.parse(await readFile(process.argv[2]!, "utf8")) as Request; - if (!input.credential || input.credential.length > (input.mode === "grok-routine-configure" ? 16384 : 8192) || !["secure-key", "canary", "gateway-token-rotation", "company-secret-create", "personal-secret-create", "grok-routine-configure", "slack-identity-confirm", "slack-endpoint-configure", "slack-wizard-confirm", "slack-wizard-resume", "slack-endpoint-reconnect"].includes(input.mode)) throw new Error("Invalid private browser request"); + if (!input.credential || input.credential.length > (input.mode === "grok-routine-configure" ? 16384 : 8192) || !["secure-key", "canary", "gateway-token-rotation", "company-secret-create", "personal-secret-create", "grok-routine-configure", "slack-identity-confirm", "slack-endpoint-configure", "slack-wizard-confirm", "slack-wizard-resume", "slack-endpoint-reconnect", "device-approve", "device-expired"].includes(input.mode)) throw new Error("Invalid private browser request"); const base = new URL(input.baseURL); if (base.hostname !== "127.0.0.1" || base.protocol !== "http:" || new URL(input.setupURL).origin !== base.origin) throw new Error("Private browser target is outside the disposable loopback instance"); const browser = await chromium.launch({ headless: true, ...(input.executablePath ? { executablePath: input.executablePath } : {}), ...(input.channel ? { channel: input.channel } : {}) }); try { const context = await browser.newContext({ baseURL: base.origin }); await context.addCookies(input.cookies); const page = await context.newPage(); page.setDefaultTimeout(30_000); + if (input.mode === "device-approve" || input.mode === "device-expired") { + if (!validDevicePrivateInput(input.mode, input.baseURL, input.setupURL, input.companyId, input.device, input.credential) || !input.device) throw new Error("Invalid finite device verification request"); + const session = await context.request.get("/api/auth/get-session"); + if (!session.ok() || (await session.json()).user?.id !== input.device.userId) throw new Error("Device UI requires the same signed-in fixture person"); + const description = page.waitForResponse(response => { + const url = new URL(response.url()); + return url.origin === base.origin && url.pathname === "/api/mcp/device" && url.searchParams.get("user_code") === input.credential && response.request().method() === "GET"; + }); + await page.goto(input.setupURL, { waitUntil: "domcontentloaded" }); + const described = await description; + if (input.mode === "device-expired") { + const body = await described.json(); + if (described.status() !== 404 || body.error !== "invalid_request") throw new Error("Expired device UI did not receive the actual issuer rejection"); + await page.getByText(/expired|invalid/i).first().waitFor(); + if (await page.getByRole("button", { name: "Connect organization", exact: true }).isVisible()) throw new Error("Expired device still offers approval"); + return { processIsolation: true, testRunnerAttached: false, tracingEnabled: false, videoEnabled: false, apiStepRecorder: false, deviceSessionVerified: true, expiredPageVisible: true }; + } + if (!described.ok()) throw new Error("Device consent description unavailable"); + const request = await described.json(); + if (request.clientName !== "Paperclip disposable CLI" || request.requestedCompanyId !== input.companyId || request.requiresSignIn !== false || request.requestedWrite !== false || request.requestedConfigure !== false || request.agentConnection === true + || !request.companies?.some((company: { id: string; name: string }) => company.id === input.companyId && company.name === input.device!.companyName)) throw new Error("Device consent does not match the exact owned read-only request"); + await page.getByRole("heading", { name: "Connect Paperclip disposable CLI to Paperclip", exact: true }).waitFor(); + await page.getByText(input.credential.toUpperCase(), { exact: true }).waitFor(); + await page.getByText(input.device.companyName, { exact: true }).waitFor(); + await page.getByText("Read all of your Paperclip data", { exact: true }).waitFor(); + if (await page.getByText("Write all of your Paperclip data", { exact: true }).isVisible()) throw new Error("Device read-only request unexpectedly offers write authority"); + const approval = page.waitForResponse(response => new URL(response.url()).origin === base.origin && new URL(response.url()).pathname === "/api/mcp/device/consent" && response.request().method() === "POST"); + await page.getByRole("button", { name: "Connect organization", exact: true }).click(); + const approved = await approval, submitted = approved.request().postDataJSON(); + if (!approved.ok() || (await approved.json()).status !== "approved" || submitted.userCode !== input.credential || submitted.companyId !== input.companyId || submitted.decision !== "approve" || submitted.allowWrites !== false || submitted.allowConfiguration !== false) throw new Error("Actual device UI did not approve exactly the selected read-only company/code"); + await page.getByRole("heading", { name: "Access approved", exact: true }).waitFor(); + return { processIsolation: true, testRunnerAttached: false, tracingEnabled: false, videoEnabled: false, apiStepRecorder: false, deviceSessionVerified: true, currentCodeVisible: true, companyConsentVisible: true, readScopeVisible: true, humanApproved: true }; + } if (input.mode === "canary") { await page.setContent(''); const field = page.getByLabel("App key"); await field.fill(input.credential); diff --git a/tests/runner-e2e/chat-catalog-domains/external-private-browser.ts b/tests/runner-e2e/chat-catalog-domains/external-private-browser.ts index 15dc917584e..9c4df43812d 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-private-browser.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-private-browser.ts @@ -1,3 +1,4 @@ +import { validDevicePrivateInput, matchesDevicePrivateReceipt, type DevicePrivateInput } from "./external-device-private-contract.js"; import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -8,8 +9,9 @@ import { chromium, type Cookie } from "@playwright/test"; import type { GrokRoutinePrivateInput } from "./external-grok-private-contract.js"; import type { SlackIdentityPrivateInput } from "./external-slack-identity-contract.js"; import type { SlackSetupPrivateInput } from "./external-slack-setup-private-contract.js"; -export interface PrivateBrowserReceipt { processIsolation: true; testRunnerAttached: false; tracingEnabled: false; videoEnabled: false; apiStepRecorder: false; credentialEntered?: boolean; connectionId?: string; challengeVisible?: boolean; passwordInput?: boolean; autocompleteOff?: boolean; humanSubmittedSecurely?: boolean; visibleReady?: boolean; storageClean?: boolean; newTokenId?: string; tokenUiVisible?: boolean; newWorkedBeforeRevocation?: boolean; oldWorkedUntilRevocation?: boolean; oldRevoked?: boolean; newWorksAfter?: boolean; oldStatusAfter?: number; otherWorksAfter?: boolean; secretId?:string; secretUiVisible?:boolean; bindingId?:string; routineConfigured?:boolean; routineUiVisible?:boolean; identityConfirmed?:boolean; identityUiVisible?:boolean; principalId?:string; paperclipUserId?:string; endpointId?:string; slackCredentialUiVisible?:boolean; activationUiVisible?:boolean; endpointActive?:boolean; identityWasAlreadyLinked?:boolean; credentialUiRejected?:boolean } -export async function privateBrowserEntry(input: { mode: "secure-key" | "canary" | "gateway-token-rotation" | "company-secret-create" | "personal-secret-create" | "grok-routine-configure" | "slack-identity-confirm" | "slack-endpoint-configure" | "slack-wizard-confirm" | "slack-wizard-resume" | "slack-endpoint-reconnect"; slackSetup?:SlackSetupPrivateInput; slackIdentity?:SlackIdentityPrivateInput; grok?:GrokRoutinePrivateInput; companyId?:string; personal?:{definitionId:string;userId:string}; gateway?: { id: string; oldTokenId: string; otherToken: string; endpointPath: string; readName: string }; baseURL: string; setupURL: string; providerURL: string; credential: string; cookies: Cookie[] }, onToken?: (token: { id: string; token: string }) => void) { +export interface PrivateBrowserReceipt { processIsolation: true; testRunnerAttached: false; tracingEnabled: false; videoEnabled: false; apiStepRecorder: false; credentialEntered?: boolean; connectionId?: string; challengeVisible?: boolean; passwordInput?: boolean; autocompleteOff?: boolean; humanSubmittedSecurely?: boolean; visibleReady?: boolean; storageClean?: boolean; newTokenId?: string; tokenUiVisible?: boolean; newWorkedBeforeRevocation?: boolean; oldWorkedUntilRevocation?: boolean; oldRevoked?: boolean; newWorksAfter?: boolean; oldStatusAfter?: number; otherWorksAfter?: boolean; secretId?:string; secretUiVisible?:boolean; bindingId?:string; routineConfigured?:boolean; routineUiVisible?:boolean; identityConfirmed?:boolean; identityUiVisible?:boolean; principalId?:string; paperclipUserId?:string; endpointId?:string; slackCredentialUiVisible?:boolean; activationUiVisible?:boolean; endpointActive?:boolean; identityWasAlreadyLinked?:boolean; credentialUiRejected?:boolean; deviceSessionVerified?:boolean; currentCodeVisible?:boolean; companyConsentVisible?:boolean; readScopeVisible?:boolean; humanApproved?:boolean; expiredPageVisible?:boolean } +export async function privateBrowserEntry(input: { mode: "secure-key" | "canary" | "gateway-token-rotation" | "company-secret-create" | "personal-secret-create" | "grok-routine-configure" | "slack-identity-confirm" | "slack-endpoint-configure" | "slack-wizard-confirm" | "slack-wizard-resume" | "slack-endpoint-reconnect" | "device-approve" | "device-expired"; device?: DevicePrivateInput; slackSetup?:SlackSetupPrivateInput; slackIdentity?:SlackIdentityPrivateInput; grok?:GrokRoutinePrivateInput; companyId?:string; personal?:{definitionId:string;userId:string}; gateway?: { id: string; oldTokenId: string; otherToken: string; endpointPath: string; readName: string }; baseURL: string; setupURL: string; providerURL: string; credential: string; cookies: Cookie[] }, onToken?: (token: { id: string; token: string }) => void) { + if (input.mode.startsWith("device-") && !validDevicePrivateInput(input.mode, input.baseURL, input.setupURL, input.companyId, input.device, input.credential)) throw new Error("Invalid finite private device UI operation"); const directory = await mkdtemp(path.join(tmpdir(), "paperclip-private-browser-")); try { const request = path.join(directory, "request.json"); @@ -24,7 +26,8 @@ export async function privateBrowserEntry(input: { mode: "secure-key" | "canary" } catch { throw new Error("Private browser entry failed; child output and credential-bearing request withheld"); } let receipt: PrivateBrowserReceipt; try { receipt = JSON.parse(output) as PrivateBrowserReceipt; } catch { throw new Error("Private browser returned invalid closed receipt"); } if (receipt.processIsolation !== true || receipt.testRunnerAttached !== false || receipt.tracingEnabled !== false || receipt.videoEnabled !== false || receipt.apiStepRecorder !== false - || Object.keys(receipt).some(key => !["processIsolation", "testRunnerAttached", "tracingEnabled", "videoEnabled", "apiStepRecorder", "credentialEntered", "connectionId", "challengeVisible", "passwordInput", "autocompleteOff", "humanSubmittedSecurely", "visibleReady", "storageClean", "newTokenId", "tokenUiVisible", "newWorkedBeforeRevocation", "oldWorkedUntilRevocation", "oldRevoked", "newWorksAfter", "oldStatusAfter", "otherWorksAfter", "secretId", "secretUiVisible", "bindingId", "routineConfigured", "routineUiVisible", "identityConfirmed", "identityUiVisible", "principalId", "paperclipUserId", "endpointId", "slackCredentialUiVisible", "activationUiVisible", "endpointActive", "identityWasAlreadyLinked", "credentialUiRejected"].includes(key))) throw new Error("Private browser receipt exceeds its closed contract"); + || Object.keys(receipt).some(key => !["processIsolation", "testRunnerAttached", "tracingEnabled", "videoEnabled", "apiStepRecorder", "credentialEntered", "connectionId", "challengeVisible", "passwordInput", "autocompleteOff", "humanSubmittedSecurely", "visibleReady", "storageClean", "newTokenId", "tokenUiVisible", "newWorkedBeforeRevocation", "oldWorkedUntilRevocation", "oldRevoked", "newWorksAfter", "oldStatusAfter", "otherWorksAfter", "secretId", "secretUiVisible", "bindingId", "routineConfigured", "routineUiVisible", "identityConfirmed", "identityUiVisible", "principalId", "paperclipUserId", "endpointId", "slackCredentialUiVisible", "activationUiVisible", "endpointActive", "identityWasAlreadyLinked", "credentialUiRejected", "deviceSessionVerified", "currentCodeVisible", "companyConsentVisible", "readScopeVisible", "humanApproved", "expiredPageVisible"].includes(key))) throw new Error("Private browser receipt exceeds its closed contract"); + if (input.mode.startsWith("device-") && !matchesDevicePrivateReceipt(input.mode, receipt)) throw new Error("Invalid closed device UI receipt"); if ((input.mode === "slack-wizard-confirm" || input.mode === "slack-wizard-resume") && (receipt.endpointId !== input.slackIdentity?.endpointId || receipt.principalId !== input.slackIdentity?.principalId || receipt.paperclipUserId !== input.slackIdentity?.userId || receipt.identityConfirmed !== true || receipt.activationUiVisible !== true || receipt.endpointActive !== true)) throw new Error("Invalid closed Slack wizard completion receipt"); if ((input.mode === "slack-endpoint-configure" || input.mode === "slack-endpoint-reconnect") && (receipt.endpointId !== input.slackSetup?.endpointId || receipt.slackCredentialUiVisible !== true || (receipt.credentialUiRejected === true ? receipt.humanSubmittedSecurely !== false : receipt.humanSubmittedSecurely !== true || receipt.storageClean !== true))) throw new Error("Invalid closed Slack credential form receipt"); if (input.mode === "slack-identity-confirm" && (receipt.identityConfirmed !== true || receipt.identityUiVisible !== true || receipt.principalId !== input.slackIdentity?.principalId || receipt.paperclipUserId !== input.slackIdentity?.userId)) throw new Error("Invalid closed Slack identity confirmation receipt"); diff --git a/tests/runner-e2e/chat-catalog-domains/external.test.ts b/tests/runner-e2e/chat-catalog-domains/external.test.ts index 088d806ed8b..1e1d2f9dea1 100644 --- a/tests/runner-e2e/chat-catalog-domains/external.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/external.test.ts @@ -59,6 +59,8 @@ function valid(id: string) { o.reply = "The launch profile returned Unpredictable launch result."; } else if (id === "assistant-connections.device-login") { Object.assign(o.state, { verificationPath: "/mcp-device", currentCodeVisible: true, companyConsentVisible: true, readScopeVisible: true, humanApproved: true, + approvalUi: { processIsolation: true, testRunnerAttached: false, tracingEnabled: false, videoEnabled: false, apiStepRecorder: false, deviceSessionVerified: true, currentCodeVisible: true, companyConsentVisible: true, readScopeVisible: true, humanApproved: true }, + expiredUi: { processIsolation: true, testRunnerAttached: false, tracingEnabled: false, videoEnabled: false, apiStepRecorder: false, deviceSessionVerified: true, expiredPageVisible: true }, clientCompanyId: "company", clientReadOnly: true, pendingError: "authorization_pending", wrongResourceDenied: true, wrongClientDenied: true, correctExchangeSucceeded: true, reusedCodeDenied: true, expirationWaitMs: 600000, elapsedBeforeExpiryProbe: 600501, expiredCodeDenied: true, expiredPageVisible: true }); o.reply = "Open the verification page and sign in to approve the CLI device code as yourself.";