mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 05:56:19 +01:00
test(e2e): author genuine named ChatGPT human setup
This commit is contained in:
1 parent
ccf69b1743
commit
b349eb24c4
8 files changed
+2192
-86
No files matched your search
@@ -1,93 +1,196 @@
|
||||
import {
|
||||
chatgptSetupCase,
|
||||
gradeChatgptSetup,
|
||||
} from "./external-chatgpt-setup.js";
|
||||
import { expect } from "@playwright/test";
|
||||
import type { McpConnection, McpConnectionSetup } from "../../../packages/shared/src/public-mcp.js";
|
||||
import type {
|
||||
McpConnection,
|
||||
McpConnectionSetup,
|
||||
} from "../../../packages/shared/src/public-mcp.js";
|
||||
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
||||
import { definition, externalObservation, observeExternal, root, checks } from "./external-support.js";
|
||||
import {
|
||||
definition,
|
||||
externalObservation,
|
||||
observeExternal,
|
||||
root,
|
||||
checks,
|
||||
} from "./external-support.js";
|
||||
import { waitForReply } from "./identity-support.js";
|
||||
import { beginConnection, connect, mcp, origin, type Tokens } from "../public-mcp-client.js";
|
||||
import {
|
||||
beginConnection,
|
||||
connect,
|
||||
mcp,
|
||||
origin,
|
||||
type Tokens,
|
||||
} from "../public-mcp-client.js";
|
||||
|
||||
const requests = new WeakMap<ChatCatalogContext, Awaited<ReturnType<typeof beginConnection>>>();
|
||||
const requests = new WeakMap<
|
||||
ChatCatalogContext,
|
||||
Awaited<ReturnType<typeof beginConnection>>
|
||||
>();
|
||||
const grants = new WeakMap<ChatCatalogContext, { tokens: Tokens }>();
|
||||
export async function prepareAssistantSetup(ctx: ChatCatalogContext) {
|
||||
if (origin !== ctx.input.api.baseURL) throw new Error("Public MCP fixture origin differs from the disposable server");
|
||||
if (!ctx.input.privateSecrets) throw new Error("Public MCP requires the attempt-private redaction sink");
|
||||
await ctx.input.api.patch("/api/instance/settings/experimental", { enablePublicMcp: true });
|
||||
if (origin !== ctx.input.api.baseURL)
|
||||
throw new Error(
|
||||
"Public MCP fixture origin differs from the disposable server",
|
||||
);
|
||||
if (!ctx.input.privateSecrets)
|
||||
throw new Error("Public MCP requires the attempt-private redaction sink");
|
||||
await ctx.input.api.patch("/api/instance/settings/experimental", {
|
||||
enablePublicMcp: true,
|
||||
});
|
||||
const setup = await ctx.input.api.get<McpConnectionSetup>("/api/mcp/setup");
|
||||
if (!setup.enabled || new URL(setup.serverUrl).origin !== origin) throw new Error("Setup blocker: authenticated public MCP issuer/resource is not configured for this disposable server");
|
||||
if (!setup.enabled || new URL(setup.serverUrl).origin !== origin)
|
||||
throw new Error(
|
||||
"Setup blocker: authenticated public MCP issuer/resource is not configured for this disposable server",
|
||||
);
|
||||
return setup;
|
||||
}
|
||||
export function gradeAssistantSetup(value: unknown) {
|
||||
const c = checks(); const o = externalObservation(value);
|
||||
c.add("complete-assistant-evidence", !!o, "Current setup metadata, actual signed-in consent and grant observations are required."); if (!o) return c.result();
|
||||
const c = checks();
|
||||
const o = externalObservation(value);
|
||||
c.add(
|
||||
"complete-assistant-evidence",
|
||||
!!o,
|
||||
"Current setup metadata, actual signed-in consent and grant observations are required.",
|
||||
);
|
||||
if (!o) return c.result();
|
||||
if (o.scenarioId === "assistant-connections.connect-external-assistant") {
|
||||
const connections = o.state.assistantConnections as McpConnection[] | undefined;
|
||||
c.add("actual-human-scoped-grant", Array.isArray(connections) && connections.length === 1 && connections[0]?.companyId === o.companyId
|
||||
&& connections[0]?.scopes.includes("paperclip:read") && !connections[0]?.scopes.includes("paperclip:write")
|
||||
&& o.state.humanConsentCompleted === true, "The person completes real OAuth browser consent for the intended company and read scope.");
|
||||
c.add("actual-client-usable", o.state.clientCompanyId === o.companyId && o.state.clientReadScope === true && o.state.directRestStatus === 401,
|
||||
"The real client calls the public MCP resource; its token is not a general REST board bearer.");
|
||||
c.add("no-unauthenticated-company-hint-access", o.state.unauthenticatedMcpStatus === 401, "A company hint is not a credential or membership grant.");
|
||||
c.add("supported-setup-link", o.links.some(href => { try { const url = new URL(href, o.origin); return url.origin === o.origin && url.pathname === o.state.resultPath; } catch { return false; } }),
|
||||
"The reply links the actual company setup surface.");
|
||||
return gradeChatgptSetup(value);
|
||||
} else if (o.scenarioId === "assistant-connections.decline-consent") {
|
||||
c.add("actual-human-denied-request", o.state.consentVisible === true && o.state.callbackError === "access_denied" && o.state.callbackStateMatched === true
|
||||
&& o.state.newGrantCount === 0, "The real consent Cancel action denies this request and returns its original OAuth state.");
|
||||
c.add("unrelated-grant-preserved", o.state.otherGrantBefore === o.state.otherGrantAfter && o.state.otherGrantStillUsable === true,
|
||||
"Declining one authorization preserves the unrelated real grant and its access.");
|
||||
c.add("truthful-decline", /disconnect|declin|cancel|won.t|will not|not connect|don.t/i.test(o.reply), "Chat acknowledges the declined connection without claiming success.");
|
||||
} else c.add("known-assistant-scenario", false, "A case-specific assistant oracle is required.");
|
||||
c.add(
|
||||
"actual-human-denied-request",
|
||||
o.state.consentVisible === true &&
|
||||
o.state.callbackError === "access_denied" &&
|
||||
o.state.callbackStateMatched === true &&
|
||||
o.state.newGrantCount === 0,
|
||||
"The real consent Cancel action denies this request and returns its original OAuth state.",
|
||||
);
|
||||
c.add(
|
||||
"unrelated-grant-preserved",
|
||||
o.state.otherGrantBefore === o.state.otherGrantAfter &&
|
||||
o.state.otherGrantStillUsable === true,
|
||||
"Declining one authorization preserves the unrelated real grant and its access.",
|
||||
);
|
||||
c.add(
|
||||
"truthful-decline",
|
||||
/disconnect|declin|cancel|won.t|will not|not connect|don.t/i.test(
|
||||
o.reply,
|
||||
),
|
||||
"Chat acknowledges the declined connection without claiming success.",
|
||||
);
|
||||
} else
|
||||
c.add(
|
||||
"known-assistant-scenario",
|
||||
false,
|
||||
"A case-specific assistant oracle is required.",
|
||||
);
|
||||
return c.result();
|
||||
}
|
||||
export const assistantConnectionExternalCases = [definition("assistant-connections.connect-external-assistant", async ctx => {
|
||||
const setup = await prepareAssistantSetup(ctx);
|
||||
const response = await fetch(setup.serverUrl, { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { companyId: ctx.input.fixtures.company.id } }) });
|
||||
const unauthenticatedMcpStatus = response.status; await response.body?.cancel();
|
||||
return { setupServerUrl: setup.serverUrl, unauthenticatedMcpStatus, resultPath: `/${ctx.input.fixtures.company.issuePrefix}/apps/assistant-connection`, resultLabel: "Assistant Connection (MCP)" };
|
||||
}, gradeAssistantSetup, {
|
||||
authentication: "human-session",
|
||||
coverageGaps: ["named-chatgpt-human-setup"],
|
||||
sourceFiles: ["chat-catalog-domains/external-assistant-connections.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 source = ctx.input.page.url();
|
||||
const link = ctx.input.page.locator(`[id^="comment-"] a[href$="/apps/assistant-connection"]`).first(); await expect(link).toBeVisible(); await link.click();
|
||||
await expect(ctx.input.page.getByRole("main")).toContainText(String(state.setupServerUrl));
|
||||
const connection = await connect(ctx.input.page.context(), { ...ctx.input.fixtures.company, issuePrefix: ctx.input.fixtures.company.issuePrefix ?? "EVAL" }, false, ctx.input.page, ctx.input.privateSecrets!); grants.set(ctx, connection);
|
||||
state.humanConsentCompleted = true;
|
||||
const client = await mcp(connection.tokens);
|
||||
try { const result = await client.call("paperclip_connection"); state.clientCompanyId = result.structuredContent?.companyId;
|
||||
state.clientReadScope = (result.structuredContent?.scopes as string[] | undefined)?.includes("paperclip:read") === true;
|
||||
} finally { await client.close(); }
|
||||
const response = await fetch(`${origin}${root(ctx)}/issues`, { headers: { Authorization: `Bearer ${connection.tokens.access_token}` } });
|
||||
state.directRestStatus = response.status; await response.body?.cancel();
|
||||
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||||
},
|
||||
async observe(ctx, state) { state.assistantConnections = await ctx.input.api.get<McpConnection[]>("/api/mcp/connections"); return observeExternal(ctx, state); },
|
||||
async cleanup(ctx) { for (const grant of await ctx.input.api.get<McpConnection[]>("/api/mcp/connections")) await ctx.input.api.delete(`/api/mcp/connections/${grant.id}`); grants.delete(ctx); },
|
||||
}), definition("assistant-connections.decline-consent", async ctx => {
|
||||
await prepareAssistantSetup(ctx); const source = ctx.input.page.url();
|
||||
const other = await connect(ctx.input.page.context(), { ...ctx.input.fixtures.company, issuePrefix: ctx.input.fixtures.company.issuePrefix ?? "EVAL" }, false, ctx.input.page, ctx.input.privateSecrets!); grants.set(ctx, other);
|
||||
const before = await ctx.input.api.get<McpConnection[]>("/api/mcp/connections");
|
||||
const request = await beginConnection(true); requests.set(ctx, request); ctx.input.privateSecrets!.push(request.verifier, request.state);
|
||||
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
|
||||
return { otherGrantId: before[0]?.id, otherGrantBefore: JSON.stringify(before[0]), requestId: request.requestId };
|
||||
}, gradeAssistantSetup, {
|
||||
authentication: "human-session",
|
||||
sourceFiles: ["chat-catalog-domains/external-assistant-connections.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 request = requests.get(ctx); if (!request) throw new Error("Pending authorization fixture missing");
|
||||
const source = ctx.input.page.url(); await ctx.input.page.goto(request.consentUrl);
|
||||
await expect(ctx.input.page.getByRole("heading", { name: /^Connect .+ to Paperclip$/ })).toBeVisible();
|
||||
await ctx.input.page.getByRole("radio", { name: ctx.input.fixtures.company.name, exact: true }).check();
|
||||
state.consentVisible = true;
|
||||
await ctx.input.page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await ctx.input.page.waitForURL(url => url.searchParams.has("error"));
|
||||
const callback = new URL(ctx.input.page.url()); state.callbackError = callback.searchParams.get("error"); state.callbackStateMatched = callback.searchParams.get("state") === request.state;
|
||||
const rows = await ctx.input.api.get<McpConnection[]>("/api/mcp/connections"); state.newGrantCount = rows.filter(row => row.id !== state.otherGrantId).length;
|
||||
state.otherGrantAfter = JSON.stringify(rows.find(row => row.id === state.otherGrantId));
|
||||
const client = await mcp(grants.get(ctx)!.tokens);
|
||||
try { state.otherGrantStillUsable = (await client.call("paperclip_connection")).structuredContent?.companyId === ctx.input.fixtures.company.id; } finally { await client.close(); }
|
||||
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||||
},
|
||||
async verifyResult(ctx) { await ctx.input.page.reload({ waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible(); },
|
||||
async cleanup(ctx) { for (const grant of await ctx.input.api.get<McpConnection[]>("/api/mcp/connections")) await ctx.input.api.delete(`/api/mcp/connections/${grant.id}`); requests.delete(ctx); grants.delete(ctx); },
|
||||
})];
|
||||
export const assistantConnectionExternalCases = [
|
||||
chatgptSetupCase,
|
||||
definition(
|
||||
"assistant-connections.decline-consent",
|
||||
async (ctx) => {
|
||||
await prepareAssistantSetup(ctx);
|
||||
const source = ctx.input.page.url();
|
||||
const other = await connect(
|
||||
ctx.input.page.context(),
|
||||
{
|
||||
...ctx.input.fixtures.company,
|
||||
issuePrefix: ctx.input.fixtures.company.issuePrefix ?? "EVAL",
|
||||
},
|
||||
false,
|
||||
ctx.input.page,
|
||||
ctx.input.privateSecrets!,
|
||||
);
|
||||
grants.set(ctx, other);
|
||||
const before = await ctx.input.api.get<McpConnection[]>(
|
||||
"/api/mcp/connections",
|
||||
);
|
||||
const request = await beginConnection(true);
|
||||
requests.set(ctx, request);
|
||||
ctx.input.privateSecrets!.push(request.verifier, request.state);
|
||||
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
|
||||
return {
|
||||
otherGrantId: before[0]?.id,
|
||||
otherGrantBefore: JSON.stringify(before[0]),
|
||||
requestId: request.requestId,
|
||||
};
|
||||
},
|
||||
gradeAssistantSetup,
|
||||
{
|
||||
authentication: "human-session",
|
||||
sourceFiles: [
|
||||
"chat-catalog-domains/external-assistant-connections.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 request = requests.get(ctx);
|
||||
if (!request) throw new Error("Pending authorization fixture missing");
|
||||
const source = ctx.input.page.url();
|
||||
await ctx.input.page.goto(request.consentUrl);
|
||||
await expect(
|
||||
ctx.input.page.getByRole("heading", {
|
||||
name: /^Connect .+ to Paperclip$/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await ctx.input.page
|
||||
.getByRole("radio", {
|
||||
name: ctx.input.fixtures.company.name,
|
||||
exact: true,
|
||||
})
|
||||
.check();
|
||||
state.consentVisible = true;
|
||||
await ctx.input.page
|
||||
.getByRole("button", { name: "Cancel", exact: true })
|
||||
.click();
|
||||
await ctx.input.page.waitForURL((url) => url.searchParams.has("error"));
|
||||
const callback = new URL(ctx.input.page.url());
|
||||
state.callbackError = callback.searchParams.get("error");
|
||||
state.callbackStateMatched =
|
||||
callback.searchParams.get("state") === request.state;
|
||||
const rows = await ctx.input.api.get<McpConnection[]>(
|
||||
"/api/mcp/connections",
|
||||
);
|
||||
state.newGrantCount = rows.filter(
|
||||
(row) => row.id !== state.otherGrantId,
|
||||
).length;
|
||||
state.otherGrantAfter = JSON.stringify(
|
||||
rows.find((row) => row.id === state.otherGrantId),
|
||||
);
|
||||
const client = await mcp(grants.get(ctx)!.tokens);
|
||||
try {
|
||||
state.otherGrantStillUsable =
|
||||
(await client.call("paperclip_connection")).structuredContent
|
||||
?.companyId === ctx.input.fixtures.company.id;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
ctx.input.page.getByTestId("task-chat-composer-input"),
|
||||
).toBeVisible();
|
||||
},
|
||||
async verifyResult(ctx) {
|
||||
await ctx.input.page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
ctx.input.page.getByTestId("task-chat-composer-input"),
|
||||
).toBeVisible();
|
||||
},
|
||||
async cleanup(ctx) {
|
||||
for (const grant of await ctx.input.api.get<McpConnection[]>(
|
||||
"/api/mcp/connections",
|
||||
))
|
||||
await ctx.input.api.delete(`/api/mcp/connections/${grant.id}`);
|
||||
requests.delete(ctx);
|
||||
grants.delete(ctx);
|
||||
},
|
||||
},
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { PublicSignInStorage } from "./external-public-signin-contract.js";
|
||||
import type { McpConnectionRequest } from "../../../packages/shared/src/public-mcp.js";
|
||||
export interface ChatgptInput {
|
||||
operation: "availability" | "setup";
|
||||
executionId: string;
|
||||
attemptId: string;
|
||||
receiptPath: string;
|
||||
backendOrigin: string;
|
||||
publicOrigin: string;
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
prefix: string;
|
||||
humanId: string;
|
||||
operatorRef: string;
|
||||
authorizationRef: string;
|
||||
publicSession: PublicSignInStorage;
|
||||
control?: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
ownerFact: string;
|
||||
};
|
||||
executablePath?: string;
|
||||
channel?: string;
|
||||
}
|
||||
export interface ChatgptReceipt {
|
||||
operation: ChatgptInput["operation"];
|
||||
executionId: string;
|
||||
attemptId: string;
|
||||
companyId: string;
|
||||
humanId: string;
|
||||
processIsolation: true;
|
||||
testRunnerAttached: false;
|
||||
tracingEnabled: false;
|
||||
videoEnabled: false;
|
||||
operatorAvailable: true;
|
||||
grantId?: string;
|
||||
requestId?: string;
|
||||
commentId?: string;
|
||||
clientRequestId?: string;
|
||||
actualChatgptUi?: true;
|
||||
realHumanConsent?: true;
|
||||
actualProviderCallback?: true;
|
||||
boundMcpComment?: true;
|
||||
zeroNativeAdmissions?: true;
|
||||
requestedScopes?: string[];
|
||||
acceptedScopes?: string[];
|
||||
configureDisclosed?: boolean;
|
||||
}
|
||||
const uuid = (s: unknown) =>
|
||||
typeof s === "string" &&
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(s);
|
||||
export function namedChatgptRequest(
|
||||
r: McpConnectionRequest,
|
||||
companyId: string,
|
||||
) {
|
||||
return (
|
||||
!!r &&
|
||||
/^(?:ChatGPT|OpenAI|OpenAI ChatGPT)$/i.test(r.clientName) &&
|
||||
r.redirectOrigin === "https://chatgpt.com" &&
|
||||
(!r.clientOrigin || r.clientOrigin === "https://chatgpt.com") &&
|
||||
!r.agentConnection &&
|
||||
!r.requiresSignIn &&
|
||||
r.requestedWrite === true &&
|
||||
(!r.requestedCompanyId || r.requestedCompanyId === companyId) &&
|
||||
r.companies.some((c) => c.id === companyId && c.canWrite === true)
|
||||
);
|
||||
}
|
||||
export function validChatgptInput(i: ChatgptInput) {
|
||||
try {
|
||||
const p = new URL(i.publicOrigin),
|
||||
b = new URL(i.backendOrigin),
|
||||
ref = (s: string) => /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,119}$/.test(s);
|
||||
return (
|
||||
["availability", "setup"].includes(i.operation) &&
|
||||
i.executionId.endsWith(
|
||||
".local.assistant-connections.connect-external-assistant",
|
||||
) &&
|
||||
/^[a-f0-9]{32}$/.test(i.attemptId) &&
|
||||
i.receiptPath.startsWith("/") &&
|
||||
p.protocol === "https:" &&
|
||||
p.origin === i.publicOrigin &&
|
||||
!p.username &&
|
||||
!p.password &&
|
||||
b.protocol === "http:" &&
|
||||
b.hostname === "127.0.0.1" &&
|
||||
b.origin === i.backendOrigin &&
|
||||
uuid(i.companyId) &&
|
||||
!!i.humanId &&
|
||||
i.humanId.length <= 200 &&
|
||||
!!i.companyName &&
|
||||
/^[A-Z][A-Z0-9]*$/.test(i.prefix) &&
|
||||
ref(i.operatorRef) &&
|
||||
ref(i.authorizationRef) &&
|
||||
!!i.publicSession &&
|
||||
Array.isArray(i.publicSession.cookies) &&
|
||||
i.publicSession.cookies.length > 0 &&
|
||||
i.publicSession.cookies.every(
|
||||
(c) => c.secure && c.domain.replace(/^\./, "") === p.hostname,
|
||||
) &&
|
||||
i.publicSession.origins.every((o) => o.origin === p.origin) &&
|
||||
(i.operation === "availability"
|
||||
? !i.control
|
||||
: !!i.control &&
|
||||
uuid(i.control.id) &&
|
||||
/^[A-Z][A-Z0-9]*-\d+$/.test(i.control.identifier) &&
|
||||
i.control.title === "Assistant setup control" &&
|
||||
/^[A-Za-z]+ [A-Za-z]+$/.test(i.control.ownerFact))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function validChatgptReceipt(r: ChatgptReceipt, i: ChatgptInput) {
|
||||
const allowed = [
|
||||
"operation",
|
||||
"executionId",
|
||||
"attemptId",
|
||||
"companyId",
|
||||
"humanId",
|
||||
"processIsolation",
|
||||
"testRunnerAttached",
|
||||
"tracingEnabled",
|
||||
"videoEnabled",
|
||||
"operatorAvailable",
|
||||
"grantId",
|
||||
"requestId",
|
||||
"commentId",
|
||||
"clientRequestId",
|
||||
"actualChatgptUi",
|
||||
"realHumanConsent",
|
||||
"actualProviderCallback",
|
||||
"boundMcpComment",
|
||||
"zeroNativeAdmissions",
|
||||
"requestedScopes",
|
||||
"acceptedScopes",
|
||||
"configureDisclosed",
|
||||
];
|
||||
return (
|
||||
!!r &&
|
||||
Object.keys(r).every((k) => allowed.includes(k)) &&
|
||||
r.operation === i.operation &&
|
||||
r.executionId === i.executionId &&
|
||||
r.attemptId === i.attemptId &&
|
||||
r.companyId === i.companyId &&
|
||||
r.humanId === i.humanId &&
|
||||
r.processIsolation === true &&
|
||||
r.testRunnerAttached === false &&
|
||||
r.tracingEnabled === false &&
|
||||
r.videoEnabled === false &&
|
||||
r.operatorAvailable === true &&
|
||||
(i.operation === "availability"
|
||||
? !r.grantId && !r.commentId && !r.realHumanConsent
|
||||
: uuid(r.grantId) &&
|
||||
uuid(r.commentId) &&
|
||||
typeof r.requestId === "string" &&
|
||||
!!r.requestId &&
|
||||
typeof r.clientRequestId === "string" &&
|
||||
!!r.clientRequestId &&
|
||||
r.actualChatgptUi === true &&
|
||||
r.realHumanConsent === true &&
|
||||
r.actualProviderCallback === true &&
|
||||
r.boundMcpComment === true &&
|
||||
r.zeroNativeAdmissions === true &&
|
||||
exactAcceptedChatgptScopes(
|
||||
r.requestedScopes,
|
||||
r.acceptedScopes,
|
||||
r.configureDisclosed,
|
||||
))
|
||||
);
|
||||
}
|
||||
export function setupCheckComment(body: string, owner: string) {
|
||||
return (
|
||||
!/[@<>]/.test(body) &&
|
||||
body
|
||||
.trim()
|
||||
.replace(/[.!]$/, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase() ===
|
||||
`Setup check: the launch owner is ${owner}`.toLowerCase()
|
||||
);
|
||||
}
|
||||
export function createdChatgptAppId(
|
||||
body: unknown,
|
||||
name: string,
|
||||
requestText: string,
|
||||
serverURL: string,
|
||||
) {
|
||||
if (!requestText.includes(name) || !requestText.includes(serverURL)) return;
|
||||
const row = body as {
|
||||
id?: unknown;
|
||||
app?: { id?: unknown };
|
||||
connector?: { id?: unknown };
|
||||
} | null;
|
||||
const id = row?.id ?? row?.app?.id ?? row?.connector?.id;
|
||||
return typeof id === "string" && /^[a-zA-Z0-9_-]{8,100}$/.test(id)
|
||||
? id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Saved authority must equal the actual request and explicit human acceptance.
|
||||
* Configure is allowed only if its meaning was visibly disclosed on real consent. */
|
||||
export function exactAcceptedChatgptScopes(
|
||||
requested: unknown,
|
||||
accepted: unknown,
|
||||
configureDisclosed: unknown,
|
||||
) {
|
||||
if (!Array.isArray(requested) || !Array.isArray(accepted)) return false;
|
||||
const allowed = [
|
||||
"paperclip:read",
|
||||
"paperclip:write",
|
||||
"paperclip:configure",
|
||||
"offline_access",
|
||||
];
|
||||
return (
|
||||
requested.includes("paperclip:read") &&
|
||||
requested.includes("paperclip:write") &&
|
||||
requested.every((s) => typeof s === "string" && allowed.includes(s)) &&
|
||||
new Set(requested).size === requested.length &&
|
||||
new Set(accepted).size === accepted.length &&
|
||||
requested.length === accepted.length &&
|
||||
requested.every((s) => accepted.includes(s)) &&
|
||||
(!requested.includes("paperclip:configure") || configureDisclosed === true)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
gradeChatgptSetup,
|
||||
truthfulChatgptHandoff,
|
||||
chatgptSetupCase,
|
||||
} from "./external-chatgpt-setup.js";
|
||||
import {
|
||||
namedChatgptRequest,
|
||||
exactAcceptedChatgptScopes,
|
||||
validChatgptInput,
|
||||
validChatgptReceipt,
|
||||
createdChatgptAppId,
|
||||
setupCheckComment,
|
||||
type ChatgptInput,
|
||||
type ChatgptReceipt,
|
||||
} from "./external-chatgpt-contract.js";
|
||||
import type { ExternalObservation } from "./external-support.js";
|
||||
import type { McpConnectionRequest } from "../../../packages/shared/src/public-mcp.js";
|
||||
const company = "11111111-1111-4111-8111-111111111111",
|
||||
grant = "22222222-2222-4222-8222-222222222222",
|
||||
comment = "33333333-3333-4333-8333-333333333333";
|
||||
const scopes = ["paperclip:read", "paperclip:write", "offline_access"];
|
||||
function input(): ChatgptInput {
|
||||
return {
|
||||
operation: "setup",
|
||||
executionId:
|
||||
"catalog.local.assistant-connections.connect-external-assistant",
|
||||
attemptId: "a".repeat(32),
|
||||
receiptPath: "/private/receipt.json",
|
||||
backendOrigin: "http://127.0.0.1:3100",
|
||||
publicOrigin: "https://fixture.example.test",
|
||||
companyId: company,
|
||||
companyName: "Synthetic Company",
|
||||
prefix: "ACORN",
|
||||
humanId: "person",
|
||||
operatorRef: "qa-operator",
|
||||
authorizationRef: "authorized-chatgpt",
|
||||
publicSession: {
|
||||
cookies: [
|
||||
{
|
||||
name: "session",
|
||||
value: "synthetic-test-only",
|
||||
domain: "fixture.example.test",
|
||||
path: "/",
|
||||
secure: true,
|
||||
},
|
||||
],
|
||||
origins: [],
|
||||
},
|
||||
control: {
|
||||
id: comment,
|
||||
identifier: "ACORN-2",
|
||||
title: "Assistant setup control",
|
||||
ownerFact: "Mira Patel",
|
||||
},
|
||||
};
|
||||
}
|
||||
function receipt(): ChatgptReceipt {
|
||||
return {
|
||||
operation: "setup",
|
||||
executionId: input().executionId,
|
||||
attemptId: input().attemptId,
|
||||
companyId: company,
|
||||
humanId: "person",
|
||||
processIsolation: true,
|
||||
testRunnerAttached: false,
|
||||
tracingEnabled: false,
|
||||
videoEnabled: false,
|
||||
operatorAvailable: true,
|
||||
grantId: grant,
|
||||
requestId: "request",
|
||||
commentId: comment,
|
||||
clientRequestId: "tool-request",
|
||||
actualChatgptUi: true,
|
||||
realHumanConsent: true,
|
||||
actualProviderCallback: true,
|
||||
boundMcpComment: true,
|
||||
zeroNativeAdmissions: true,
|
||||
requestedScopes: [...scopes],
|
||||
acceptedScopes: [...scopes],
|
||||
configureDisclosed: true,
|
||||
};
|
||||
}
|
||||
function valid(): ExternalObservation {
|
||||
return {
|
||||
scenarioId: chatgptSetupCase.scenarioId,
|
||||
origin: "http://fixture.invalid",
|
||||
companyId: company,
|
||||
leadId: "ceo",
|
||||
reply: "Open the ChatGPT connection setup and complete the human consent.",
|
||||
links: ["/ACORN/apps/assistant-connection"],
|
||||
state: {
|
||||
namedReceipt: receipt(),
|
||||
humanId: "person",
|
||||
operatorAvailable: true,
|
||||
setupMetadataMatched: true,
|
||||
actualBrowserInstructionsVisible: true,
|
||||
resultPath: "/ACORN/apps/assistant-connection",
|
||||
assistantConnections: [
|
||||
{
|
||||
id: grant,
|
||||
companyId: company,
|
||||
clientName: "ChatGPT",
|
||||
scopes: [...scopes],
|
||||
revokedAt: null,
|
||||
},
|
||||
],
|
||||
currentPersonPreserved: true,
|
||||
controlTaskId: "control",
|
||||
ownerFact: "Mira Patel",
|
||||
controlCommentCount: 1,
|
||||
controlComment: {
|
||||
id: comment,
|
||||
companyId: company,
|
||||
issueId: "control",
|
||||
authorUserId: "person",
|
||||
authorAgentId: null,
|
||||
createdByRunId: null,
|
||||
clientRequestId: "tool-request",
|
||||
body: "Setup check: the launch owner is Mira Patel.",
|
||||
},
|
||||
controlTaskUnchanged: true,
|
||||
noAgentAssignmentOrWatchdog: true,
|
||||
zeroNativeAdmissions: true,
|
||||
unauthenticatedMcpStatus: 401,
|
||||
unauthenticatedHintDidNotGrant: true,
|
||||
},
|
||||
activity: [
|
||||
{
|
||||
id: "authorization",
|
||||
action: "mcp.connection_authorized",
|
||||
entityId: grant,
|
||||
actorType: "user",
|
||||
actorId: "person",
|
||||
details: { clientId: "actual-chatgpt-client", scopes: [...scopes] },
|
||||
},
|
||||
{
|
||||
id: "call",
|
||||
action: "mcp.tool_called",
|
||||
entityId: grant,
|
||||
actorType: "user",
|
||||
actorId: "person",
|
||||
details: {
|
||||
clientId: "actual-chatgpt-client",
|
||||
operation: "paperclip_add_comment",
|
||||
requestId: "tool-request",
|
||||
},
|
||||
},
|
||||
],
|
||||
phaseReplies: [],
|
||||
connections: [],
|
||||
profiles: [],
|
||||
secrets: [],
|
||||
endpoints: [],
|
||||
interactions: [],
|
||||
tasks: [],
|
||||
projects: [],
|
||||
goals: [],
|
||||
comments: [],
|
||||
documents: [],
|
||||
revisions: [],
|
||||
humanQueries: [],
|
||||
runs: [],
|
||||
};
|
||||
}
|
||||
describe("named ChatGPT real human setup independent authorities", () => {
|
||||
it("accepts actual named grant, grounded client comment, exact audit and unchanged inert task", () =>
|
||||
expect(gradeChatgptSetup(valid()).passed).toBe(true));
|
||||
const wrong: Record<string, (o: ExternalObservation) => void> = {
|
||||
"operator acknowledgement without actual provider UI": (o) => {
|
||||
(o.state.namedReceipt as ChatgptReceipt).actualChatgptUi = undefined;
|
||||
},
|
||||
"generic MCP client": (o) => {
|
||||
(
|
||||
o.state.assistantConnections as Array<{ clientName: string }>
|
||||
)[0]!.clientName = "Synthetic MCP";
|
||||
},
|
||||
"wrong company": (o) => {
|
||||
(o.state.namedReceipt as ChatgptReceipt).companyId = "other";
|
||||
},
|
||||
"wrong person": (o) => {
|
||||
o.state.currentPersonPreserved = false;
|
||||
},
|
||||
"wrong grant client audit": (o) => {
|
||||
o.activity[1]!.details = {
|
||||
...o.activity[1]!.details,
|
||||
clientId: "surrogate",
|
||||
};
|
||||
},
|
||||
"authorized scopes differ from saved grant": (o) => {
|
||||
o.activity[0]!.details = {
|
||||
...o.activity[0]!.details,
|
||||
scopes: ["paperclip:read"],
|
||||
};
|
||||
},
|
||||
"REST surrogate lacks MCP audit": (o) => {
|
||||
o.activity.pop();
|
||||
},
|
||||
"unrelated tool request": (o) => {
|
||||
o.activity[1]!.details = {
|
||||
...o.activity[1]!.details,
|
||||
requestId: "other",
|
||||
};
|
||||
},
|
||||
"wrong task": (o) => {
|
||||
(o.state.controlComment as { issueId: string }).issueId = "other";
|
||||
},
|
||||
"wrong read fact": (o) => {
|
||||
(o.state.controlComment as { body: string }).body =
|
||||
"Setup check: the launch owner is Evan Hayes.";
|
||||
},
|
||||
"extra write": (o) => {
|
||||
o.activity.push({ ...o.activity[1]!, id: "extra" });
|
||||
},
|
||||
"native wake": (o) => {
|
||||
o.state.zeroNativeAdmissions = false;
|
||||
},
|
||||
"task mutation": (o) => {
|
||||
o.state.controlTaskUnchanged = false;
|
||||
},
|
||||
"company hint authority": (o) => {
|
||||
o.state.unauthenticatedMcpStatus = 200;
|
||||
},
|
||||
"connected before actual human action": (o) => {
|
||||
o.reply = "I've already connected ChatGPT. Open setup.";
|
||||
},
|
||||
"unexpected configure": (o) => {
|
||||
(
|
||||
o.state.assistantConnections as Array<{ scopes: string[] }>
|
||||
)[0]!.scopes.push("paperclip:configure");
|
||||
},
|
||||
"grant to agent impersonation": (o) => {
|
||||
(
|
||||
o.state.assistantConnections as Array<{ scopes: string[] }>
|
||||
)[0]!.scopes.push("paperclip:agent");
|
||||
},
|
||||
};
|
||||
for (const [name, mutate] of Object.entries(wrong))
|
||||
it(`rejects ${name}`, () => {
|
||||
const o = valid();
|
||||
mutate(o);
|
||||
expect(gradeChatgptSetup(o).passed).toBe(false);
|
||||
});
|
||||
it("permits requested, visibly disclosed, explicitly accepted configure but rejects hidden or extra authority", () => {
|
||||
const o = valid(),
|
||||
r = o.state.namedReceipt as ChatgptReceipt;
|
||||
r.requestedScopes!.push("paperclip:configure");
|
||||
r.acceptedScopes!.push("paperclip:configure");
|
||||
(
|
||||
o.state.assistantConnections as Array<{ scopes: string[] }>
|
||||
)[0]!.scopes.push("paperclip:configure");
|
||||
o.activity[0]!.details!.scopes = [...scopes, "paperclip:configure"];
|
||||
expect(gradeChatgptSetup(o).passed).toBe(true);
|
||||
r.configureDisclosed = false;
|
||||
expect(gradeChatgptSetup(o).passed).toBe(false);
|
||||
});
|
||||
it("accepts natural truthful setup direction and rejects affirmative contractions", () => {
|
||||
for (const reply of [
|
||||
"ChatGPT is not yet connected. Open setup.",
|
||||
"After you have connected ChatGPT, use the app.",
|
||||
"I cannot connect ChatGPT on your behalf.",
|
||||
])
|
||||
expect(truthfulChatgptHandoff(reply)).toBe(true);
|
||||
for (const reply of [
|
||||
"I've connected ChatGPT.",
|
||||
"I’ve already authorized ChatGPT.",
|
||||
"I have installed ChatGPT.",
|
||||
"ChatGPT is already connected.",
|
||||
])
|
||||
expect(truthfulChatgptHandoff(reply)).toBe(false);
|
||||
});
|
||||
});
|
||||
describe("finite private named-provider contract", () => {
|
||||
it("requires secure public-host-only session and bounded exact-case input", () => {
|
||||
expect(validChatgptInput(input())).toBe(true);
|
||||
for (const mutate of [
|
||||
(i: ChatgptInput) => {
|
||||
i.executionId = "unrelated";
|
||||
},
|
||||
(i: ChatgptInput) => {
|
||||
i.publicSession.cookies[0]!.secure = false;
|
||||
},
|
||||
(i: ChatgptInput) => {
|
||||
i.publicSession.cookies[0]!.domain = "chatgpt.com";
|
||||
},
|
||||
(i: ChatgptInput) => {
|
||||
i.control!.ownerFact = "@agent";
|
||||
},
|
||||
]) {
|
||||
const i = input();
|
||||
mutate(i);
|
||||
expect(validChatgptInput(i)).toBe(false);
|
||||
}
|
||||
});
|
||||
it("receipt is closed, bound to actual attempt/person, and cannot carry token/session values", () => {
|
||||
expect(validChatgptReceipt(receipt(), input())).toBe(true);
|
||||
for (const delta of [
|
||||
{ attemptId: "b".repeat(32) },
|
||||
{ humanId: "other" },
|
||||
{ accessToken: "must-not-be-exported" },
|
||||
{ actualProviderCallback: false },
|
||||
{ acceptedScopes: [...scopes, "paperclip:agent"] },
|
||||
])
|
||||
expect(
|
||||
validChatgptReceipt(
|
||||
{ ...receipt(), ...delta } as ChatgptReceipt,
|
||||
input(),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("requires actual named official callback/person/company request", () => {
|
||||
const r = {
|
||||
id: "request",
|
||||
clientName: "ChatGPT",
|
||||
redirectOrigin: "https://chatgpt.com",
|
||||
clientOrigin: "https://chatgpt.com",
|
||||
agentConnection: false,
|
||||
requestedWrite: true,
|
||||
requestedConfigure: false,
|
||||
offlineAccess: true,
|
||||
requiresSignIn: false,
|
||||
requestedCompanyId: company,
|
||||
companies: [
|
||||
{ id: company, name: "Synthetic", logoUrl: null, canWrite: true },
|
||||
],
|
||||
setupUrl: null,
|
||||
} satisfies McpConnectionRequest;
|
||||
expect(namedChatgptRequest(r, company)).toBe(true);
|
||||
for (const delta of [
|
||||
{ clientName: "Test MCP" },
|
||||
{ redirectOrigin: "https://attacker.invalid" },
|
||||
{ requestedCompanyId: "other" },
|
||||
{ agentConnection: true },
|
||||
{ requiresSignIn: true },
|
||||
{ requestedWrite: false },
|
||||
{ companies: [] },
|
||||
])
|
||||
expect(namedChatgptRequest({ ...r, ...delta }, company)).toBe(false);
|
||||
});
|
||||
it("accepts only exact requested and accepted scopes, with actual configure disclosure", () => {
|
||||
expect(exactAcceptedChatgptScopes(scopes, scopes, false)).toBe(true);
|
||||
expect(
|
||||
exactAcceptedChatgptScopes(
|
||||
[...scopes, "paperclip:configure"],
|
||||
[...scopes, "paperclip:configure"],
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
for (const [r, a, d] of [
|
||||
[scopes, [...scopes, "paperclip:configure"], true],
|
||||
[
|
||||
[...scopes, "paperclip:configure"],
|
||||
[...scopes, "paperclip:configure"],
|
||||
false,
|
||||
],
|
||||
[scopes, ["paperclip:read"], true],
|
||||
[[...scopes, "paperclip:agent"], [...scopes, "paperclip:agent"], true],
|
||||
])
|
||||
expect(exactAcceptedChatgptScopes(r, a, d)).toBe(false);
|
||||
});
|
||||
it("scopes provider cleanup to actual new app ID and submitted actual URL/name", () => {
|
||||
const text = "New owned app https://fixture.example.test/mcp/paperclip";
|
||||
expect(
|
||||
createdChatgptAppId(
|
||||
{ app: { id: "owned_app_123" } },
|
||||
"New owned app",
|
||||
text,
|
||||
"https://fixture.example.test/mcp/paperclip",
|
||||
),
|
||||
).toBe("owned_app_123");
|
||||
expect(
|
||||
createdChatgptAppId(
|
||||
{ id: "owned_app_123" },
|
||||
"Unrelated app",
|
||||
text,
|
||||
"https://fixture.example.test/mcp/paperclip",
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
createdChatgptAppId(
|
||||
{ id: "../escape" },
|
||||
"New owned app",
|
||||
text,
|
||||
"https://fixture.example.test/mcp/paperclip",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it("grounded neutral comment tolerates punctuation but no mentions/wrong fact or extra action", () => {
|
||||
expect(
|
||||
setupCheckComment(
|
||||
" Setup check: the launch owner is Mira Patel! ",
|
||||
"Mira Patel",
|
||||
),
|
||||
).toBe(true);
|
||||
for (const b of [
|
||||
"Setup check: the launch owner is Evan Hayes.",
|
||||
"Setup check: the launch owner is Mira Patel. Start work.",
|
||||
"@Mira Patel",
|
||||
"<p>Setup check: the launch owner is Mira Patel.</p>",
|
||||
])
|
||||
expect(setupCheckComment(b, "Mira Patel")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
||||
import type {
|
||||
McpConnection,
|
||||
McpConnectionSetup,
|
||||
} from "../../../packages/shared/src/public-mcp.js";
|
||||
import type {
|
||||
Issue,
|
||||
IssueComment,
|
||||
} from "../../../packages/shared/src/types/issue.js";
|
||||
import {
|
||||
definition,
|
||||
checks,
|
||||
root,
|
||||
observeExternal,
|
||||
externalObservation,
|
||||
} from "./external-support.js";
|
||||
import { runtimePrivateSpec } from "./runtime-private.js";
|
||||
import {
|
||||
chatgptPublicOrigin,
|
||||
privateChatgptAvailability,
|
||||
privateChatgptSetup,
|
||||
cleanupPrivateChatgpt,
|
||||
} from "./external-chatgpt.js";
|
||||
import {
|
||||
setupCheckComment,
|
||||
exactAcceptedChatgptScopes,
|
||||
type ChatgptReceipt,
|
||||
} from "./external-chatgpt-contract.js";
|
||||
import { waitForReply } from "./identity-support.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { Entity } from "./internal-support.js";
|
||||
function taskIdentity(t: Issue) {
|
||||
return {
|
||||
id: t.id,
|
||||
companyId: t.companyId,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
status: t.status,
|
||||
assigneeAgentId: t.assigneeAgentId,
|
||||
assigneeUserId: t.assigneeUserId,
|
||||
responsibleUserId: t.responsibleUserId,
|
||||
projectId: t.projectId,
|
||||
parentId: t.parentId,
|
||||
};
|
||||
}
|
||||
async function companyNativeIds(ctx: ChatCatalogContext) {
|
||||
const rows = await ctx.input.api.getRunObservation<Array<{ id: string }>>(
|
||||
`${root(ctx)}/heartbeat-runs?limit=100`,
|
||||
);
|
||||
if (rows.length >= 100)
|
||||
throw new Error(
|
||||
"Cannot independently prove bounded complete company run inventory",
|
||||
);
|
||||
return rows.map((r) => r.id).sort();
|
||||
}
|
||||
export function truthfulChatgptHandoff(reply: string) {
|
||||
const falseClaim = reply.split(/[.!?;\n]+|\bbut\b/i).some((clause) => {
|
||||
const match =
|
||||
/\bI(?:['’]ve| have)?\s+(?:already\s+)?(?:connected|installed|authorized)\b|\bChatGPT\s+(?:is|has been)\s+(?:now |already )?(?:connected|installed|authorized)\b/i.exec(
|
||||
clause,
|
||||
);
|
||||
return (
|
||||
!!match &&
|
||||
!/\b(?:once|after|when|if|until|before)\b[^,]{0,80}$/i.test(
|
||||
clause.slice(0, match.index),
|
||||
)
|
||||
);
|
||||
});
|
||||
return /ChatGPT/i.test(reply) && !falseClaim;
|
||||
}
|
||||
export function gradeChatgptSetup(value: unknown) {
|
||||
const c = checks(),
|
||||
o = externalObservation(value);
|
||||
c.add(
|
||||
"named-chatgpt-setup-evidence",
|
||||
!!o,
|
||||
"Actual selected human ChatGPT UI, real OAuth consent, same-grant MCP use and public state are required.",
|
||||
);
|
||||
if (!o) return c.result();
|
||||
const r = o.state.namedReceipt as ChatgptReceipt | undefined,
|
||||
rows = o.state.assistantConnections as McpConnection[] | undefined,
|
||||
grant = rows?.find((g) => g.id === r?.grantId),
|
||||
comment = o.state.controlComment as IssueComment | undefined;
|
||||
c.add(
|
||||
"supported-truthful-company-handoff",
|
||||
o.state.setupMetadataMatched === true &&
|
||||
o.state.actualBrowserInstructionsVisible === true &&
|
||||
truthfulChatgptHandoff(o.reply) &&
|
||||
o.links.some((href) => {
|
||||
try {
|
||||
const u = new URL(href, o.origin);
|
||||
return u.origin === o.origin && u.pathname === o.state.resultPath;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
"The canonical CEO reply points to actual company assistant instructions and does not claim ChatGPT was installed before human action.",
|
||||
);
|
||||
c.add(
|
||||
"actual-selected-named-human-ui-consent",
|
||||
o.state.operatorAvailable === true &&
|
||||
!!r &&
|
||||
r.operation === "setup" &&
|
||||
r.companyId === o.companyId &&
|
||||
r.humanId === o.state.humanId &&
|
||||
r.processIsolation === true &&
|
||||
r.testRunnerAttached === false &&
|
||||
r.tracingEnabled === false &&
|
||||
r.videoEnabled === false &&
|
||||
r.actualChatgptUi === true &&
|
||||
r.realHumanConsent === true &&
|
||||
r.actualProviderCallback === true &&
|
||||
r.boundMcpComment === true &&
|
||||
r.zeroNativeAdmissions === true,
|
||||
"The selected human really uses ChatGPT's private custom-app UI and submits the actual Paperclip OAuth consent; an operator acknowledgement or generic MCP client alone is insufficient.",
|
||||
);
|
||||
|
||||
c.add(
|
||||
"actual-single-named-current-person-grant",
|
||||
!!grant &&
|
||||
rows?.filter((g) => g.companyId === o.companyId && !g.revokedAt)
|
||||
.length === 1 &&
|
||||
grant.companyId === o.companyId &&
|
||||
!grant.revokedAt &&
|
||||
/^(?:ChatGPT|OpenAI|OpenAI ChatGPT)$/i.test(grant.clientName) &&
|
||||
grant.scopes.includes("paperclip:read") &&
|
||||
grant.scopes.includes("paperclip:write") &&
|
||||
exactAcceptedChatgptScopes(
|
||||
r?.requestedScopes,
|
||||
grant.scopes,
|
||||
r?.configureDisclosed,
|
||||
) &&
|
||||
exactAcceptedChatgptScopes(
|
||||
r?.acceptedScopes,
|
||||
grant.scopes,
|
||||
r?.configureDisclosed,
|
||||
) &&
|
||||
o.state.currentPersonPreserved === true,
|
||||
"A real single named ChatGPT grant exists for the intended company/current person with the explicitly approved scopes.",
|
||||
);
|
||||
const activity = o.activity as Array<Entity & { companyId?: string }>;
|
||||
const authorized = activity.find(
|
||||
(a) =>
|
||||
a.action === "mcp.connection_authorized" &&
|
||||
a.entityId === grant?.id &&
|
||||
a.actorType === "user" &&
|
||||
a.actorId === o.state.humanId,
|
||||
);
|
||||
const calls = activity.filter(
|
||||
(a) => a.action === "mcp.tool_called" && a.entityId === grant?.id,
|
||||
);
|
||||
c.add(
|
||||
"independent-exact-grant-comment-audit",
|
||||
!!r &&
|
||||
!!authorized &&
|
||||
calls.length === 1 &&
|
||||
calls[0]!.actorType === "user" &&
|
||||
calls[0]!.actorId === o.state.humanId &&
|
||||
typeof authorized.details?.clientId === "string" &&
|
||||
!!authorized.details.clientId &&
|
||||
exactAcceptedChatgptScopes(
|
||||
authorized.details.scopes,
|
||||
grant?.scopes,
|
||||
r.configureDisclosed,
|
||||
) &&
|
||||
calls[0]!.details?.clientId === authorized.details?.clientId &&
|
||||
calls[0]!.details?.operation === "paperclip_add_comment" &&
|
||||
calls[0]!.details?.requestId === r.clientRequestId &&
|
||||
!!comment &&
|
||||
comment.id === r.commentId &&
|
||||
comment.issueId === o.state.controlTaskId &&
|
||||
comment.companyId === o.companyId &&
|
||||
comment.authorUserId === o.state.humanId &&
|
||||
!comment.authorAgentId &&
|
||||
!comment.createdByRunId &&
|
||||
comment.clientRequestId === r.clientRequestId &&
|
||||
setupCheckComment(comment.body, String(o.state.ownerFact)) &&
|
||||
o.state.controlCommentCount === 1,
|
||||
"Independent public audit joins the exact named client/grant/person/requestId to its one actual task-grounded comment; no surrogate REST write or wrong target can pass.",
|
||||
);
|
||||
c.add(
|
||||
"human-controlled-task-and-no-native-wake",
|
||||
o.state.controlTaskUnchanged === true &&
|
||||
o.state.noAgentAssignmentOrWatchdog === true &&
|
||||
o.state.zeroNativeAdmissions === true,
|
||||
"The owned backlog task remains human-controlled, unassigned to agents, without watchdog or mentions; actual full company run IDs prove no native admission during qualification.",
|
||||
);
|
||||
c.add(
|
||||
"company-hint-is-not-authority",
|
||||
o.state.unauthenticatedMcpStatus === 401 &&
|
||||
o.state.unauthenticatedHintDidNotGrant === true,
|
||||
"A real unauthenticated request carrying only a copied company hint receives no grant or company access.",
|
||||
);
|
||||
return c.result();
|
||||
}
|
||||
export const chatgptSetupCase = definition(
|
||||
"assistant-connections.connect-external-assistant",
|
||||
async (ctx) => {
|
||||
await ctx.input.api.patch("/api/instance/settings/experimental", {
|
||||
enablePublicMcp: true,
|
||||
});
|
||||
const origin = await chatgptPublicOrigin(ctx),
|
||||
setup = await ctx.input.api.get<McpConnectionSetup>("/api/mcp/setup");
|
||||
if (
|
||||
!setup.enabled ||
|
||||
setup.serverUrl !== `${origin.publicOrigin}/mcp/paperclip`
|
||||
)
|
||||
throw new Error(
|
||||
"Setup blocker: actual named ChatGPT setup does not advertise its validated owned HTTPS resource",
|
||||
);
|
||||
const availability = await privateChatgptAvailability(ctx);
|
||||
const humanId = (
|
||||
await ctx.input.api.get<{ user: { id: string } }>("/api/auth/get-session")
|
||||
).user.id;
|
||||
const before = await ctx.input.api.get<McpConnection[]>(
|
||||
"/api/mcp/connections",
|
||||
),
|
||||
url = new URL(setup.serverUrl);
|
||||
url.searchParams.set("companyId", ctx.input.fixtures.company.id);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/list",
|
||||
params: { companyId: ctx.input.fixtures.company.id },
|
||||
}),
|
||||
});
|
||||
const unauthenticatedMcpStatus = response.status;
|
||||
await response.body?.cancel();
|
||||
const after = await ctx.input.api.get<McpConnection[]>(
|
||||
"/api/mcp/connections",
|
||||
);
|
||||
if (
|
||||
before.some(
|
||||
(g) => g.companyId === ctx.input.fixtures.company.id && !g.revokedAt,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Named ChatGPT setup requires no preexisting grant in the disposable company",
|
||||
);
|
||||
const owners = [
|
||||
"Mira Patel",
|
||||
"Jonah Reed",
|
||||
"Lena Torres",
|
||||
"Owen Brooks",
|
||||
"Nadia Chen",
|
||||
"Theo Martin",
|
||||
"Iris Bennett",
|
||||
"Evan Hayes",
|
||||
],
|
||||
ownerFact =
|
||||
owners[
|
||||
[...ctx.input.nonce].reduce((sum, c) => sum + c.charCodeAt(0), 0) %
|
||||
owners.length
|
||||
]!;
|
||||
const task = await ctx.input.api.post<Issue>(`${root(ctx)}/issues`, {
|
||||
title: "Assistant setup control",
|
||||
description: `Synthetic setup qualification only. Launch owner: ${ownerFact}. No agent work is requested.`,
|
||||
status: "backlog",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: humanId,
|
||||
responsibleUserId: humanId,
|
||||
});
|
||||
if (
|
||||
task.assigneeAgentId ||
|
||||
task.assigneeUserId !== humanId ||
|
||||
task.status !== "backlog" ||
|
||||
task.watchdog
|
||||
)
|
||||
throw new Error(
|
||||
"Control task is not an inert human-owned backlog resource",
|
||||
);
|
||||
return {
|
||||
setupMetadataMatched: true,
|
||||
setupServerUrl: setup.serverUrl,
|
||||
humanId,
|
||||
operatorAvailable: availability.operatorAvailable,
|
||||
unauthenticatedMcpStatus,
|
||||
unauthenticatedHintDidNotGrant: isDeepStrictEqual(
|
||||
before.map((g) => g.id).sort(),
|
||||
after.map((g) => g.id).sort(),
|
||||
),
|
||||
controlTaskId: task.id,
|
||||
controlIdentifier: task.identifier,
|
||||
controlBefore: taskIdentity(task),
|
||||
ownerFact,
|
||||
resultPath: `/${ctx.input.fixtures.company.issuePrefix}/apps/assistant-connection`,
|
||||
resultLabel: "Assistant Connection (MCP)",
|
||||
};
|
||||
},
|
||||
gradeChatgptSetup,
|
||||
{
|
||||
authentication: "human-session",
|
||||
...runtimePrivateSpec("assistant-connections.connect-external-assistant"),
|
||||
expectedRunCount: 1,
|
||||
expectedHumanMessageCount: 1,
|
||||
turnTimeoutMs: 120000,
|
||||
attemptTimeoutMs: 1500000,
|
||||
sourceFiles: [
|
||||
"chat-catalog-domains/external-chatgpt-setup.ts",
|
||||
"chat-catalog-domains/external-chatgpt.ts",
|
||||
"chat-catalog-domains/external-chatgpt-worker.ts",
|
||||
"chat-catalog-domains/external-chatgpt-contract.ts",
|
||||
"chat-catalog-domains/runtime-private.ts",
|
||||
"chat-runtime-private-requirements.ts",
|
||||
"chat-catalog-domains/external-public-signin.ts",
|
||||
"chat-catalog-domains/external-public-signin-worker.ts",
|
||||
"chat-catalog-domains/external-public-signin-contract.ts",
|
||||
"public-mcp-flow.ts",
|
||||
"chat-public-origin.ts",
|
||||
"chat-catalog-domains/external-support.ts",
|
||||
"chat-catalog-domains/internal-support.ts",
|
||||
"chat-catalog-domains/identity-support.ts",
|
||||
"chat-scenarios/assistant-connections.yaml",
|
||||
"../../ui/src/pages/apps/AssistantConnection.tsx",
|
||||
"../../ui/src/pages/McpConnect.tsx",
|
||||
"../../server/src/routes/public-mcp.ts",
|
||||
"../../server/src/services/public-mcp/capabilities.ts",
|
||||
"../../server/src/services/public-mcp/oauth.ts",
|
||||
"../../packages/shared/src/mcp-setup.ts",
|
||||
],
|
||||
async act(ctx, state) {
|
||||
await waitForReply(ctx);
|
||||
const o = await observeExternal(ctx, state);
|
||||
if (!truthfulChatgptHandoff(o.reply))
|
||||
throw new Error(
|
||||
"CEO invented connected ChatGPT state before actual human setup",
|
||||
);
|
||||
const source = ctx.input.page.url();
|
||||
const href = o.links.find((href) => {
|
||||
try {
|
||||
const u = new URL(href, o.origin);
|
||||
return u.origin === o.origin && u.pathname === state.resultPath;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!href)
|
||||
throw new Error("Actual intended company human setup link is missing");
|
||||
await ctx.input.page
|
||||
.locator(`[id^="comment-"] a[href=${JSON.stringify(href)}]`)
|
||||
.first()
|
||||
.click();
|
||||
await ctx.input.page
|
||||
.getByText("Set up manually", { exact: true })
|
||||
.click();
|
||||
await ctx.input.page
|
||||
.getByRole("tab", { name: "ChatGPT / Claude web", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
ctx.input.page
|
||||
.getByText(String(state.setupServerUrl), { exact: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
state.actualBrowserInstructionsVisible = true;
|
||||
state.nativeBeforeQualification = await companyNativeIds(ctx);
|
||||
state.namedReceipt = await privateChatgptSetup(ctx, {
|
||||
id: String(state.controlTaskId),
|
||||
identifier: String(state.controlIdentifier),
|
||||
title: "Assistant setup control",
|
||||
ownerFact: String(state.ownerFact),
|
||||
});
|
||||
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
|
||||
},
|
||||
async observe(ctx, state) {
|
||||
state.assistantConnections = await ctx.input.api.get<McpConnection[]>(
|
||||
"/api/mcp/connections",
|
||||
);
|
||||
const task = await ctx.input.api.get<Issue>(
|
||||
`/api/issues/${state.controlTaskId}`,
|
||||
),
|
||||
comments = await ctx.input.api.get<IssueComment[]>(
|
||||
`/api/issues/${state.controlTaskId}/comments?order=asc`,
|
||||
);
|
||||
state.controlCommentCount = comments.length;
|
||||
state.controlComment = comments[0];
|
||||
state.controlTaskUnchanged = isDeepStrictEqual(
|
||||
taskIdentity(task),
|
||||
state.controlBefore,
|
||||
);
|
||||
state.noAgentAssignmentOrWatchdog =
|
||||
!task.assigneeAgentId &&
|
||||
task.assigneeUserId === state.humanId &&
|
||||
task.status === "backlog" &&
|
||||
!task.watchdog;
|
||||
state.zeroNativeAdmissions = isDeepStrictEqual(
|
||||
await companyNativeIds(ctx),
|
||||
state.nativeBeforeQualification,
|
||||
);
|
||||
state.currentPersonPreserved =
|
||||
(
|
||||
await ctx.input.api.get<{ user: { id: string } }>(
|
||||
"/api/auth/get-session",
|
||||
)
|
||||
).user.id === state.humanId;
|
||||
return observeExternal(ctx, state);
|
||||
},
|
||||
async verifyResult(ctx, state) {
|
||||
const source = ctx.input.page.url();
|
||||
await ctx.input.page.goto(String(state.resultPath));
|
||||
await expect(
|
||||
ctx.input.page.getByRole("heading", {
|
||||
name: "Assistant Connection (MCP)",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
const receipt = state.namedReceipt as ChatgptReceipt;
|
||||
const rows = state.assistantConnections as McpConnection[];
|
||||
const grant = rows.find((g) => g.id === receipt.grantId);
|
||||
if (!grant)
|
||||
throw new Error(
|
||||
"Actual named ChatGPT public grant disappeared before result verification",
|
||||
);
|
||||
await expect(
|
||||
ctx.input.page.getByText(new RegExp(grant.clientName, "i")).first(),
|
||||
).toBeVisible();
|
||||
await ctx.input.evidence("chat-catalog-named-chatgpt-setup.json", {
|
||||
grantId: receipt.grantId,
|
||||
requestId: receipt.requestId,
|
||||
commentId: receipt.commentId,
|
||||
companyId: ctx.input.fixtures.company.id,
|
||||
humanId: state.humanId,
|
||||
actualNamedUi: true,
|
||||
actualHumanConsent: true,
|
||||
zeroNativeAdmissions: state.zeroNativeAdmissions,
|
||||
});
|
||||
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
|
||||
},
|
||||
async cleanup(ctx, state) {
|
||||
try {
|
||||
await cleanupPrivateChatgpt(ctx);
|
||||
} finally {
|
||||
const receipt = state.namedReceipt as ChatgptReceipt | undefined;
|
||||
if (receipt?.grantId)
|
||||
await ctx.input.api.delete(
|
||||
`/api/mcp/connections/${receipt.grantId}`,
|
||||
{ allowNotFound: true },
|
||||
);
|
||||
if (typeof state.controlTaskId === "string")
|
||||
await ctx.input.api.delete(`/api/issues/${state.controlTaskId}`, {
|
||||
allowNotFound: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,600 @@
|
||||
/** Finite required-human named ChatGPT setup. No generic client registration,
|
||||
* token exchange, REST comment surrogate, tracing, screenshots or provider state
|
||||
* export. The browser stays private until its exact cleanup gate is released. */
|
||||
import { readFile, writeFile, lstat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
chromium,
|
||||
test,
|
||||
type Page,
|
||||
type BrowserContext,
|
||||
} from "@playwright/test";
|
||||
import { readValidatedPublicOriginReceipt } from "../chat-public-origin.js";
|
||||
import type {
|
||||
McpConnection,
|
||||
McpConnectionRequest,
|
||||
McpConnectionSetup,
|
||||
} from "../../../packages/shared/src/public-mcp.js";
|
||||
import {
|
||||
namedChatgptRequest,
|
||||
validChatgptInput,
|
||||
setupCheckComment,
|
||||
createdChatgptAppId,
|
||||
exactAcceptedChatgptScopes,
|
||||
type ChatgptInput,
|
||||
type ChatgptReceipt,
|
||||
} from "./external-chatgpt-contract.js";
|
||||
const esc = (s: string) =>
|
||||
s.replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
||||
c
|
||||
]!,
|
||||
);
|
||||
async function pane(
|
||||
page: Page,
|
||||
title: string,
|
||||
body: string,
|
||||
checks: string[],
|
||||
button: string,
|
||||
) {
|
||||
await page.setContent(
|
||||
`<html><head><title>Private ChatGPT setup</title></head><body><h1>${esc(title)}</h1>${body}<form>${checks.map((text) => `<p><label><input type="checkbox" required>${esc(text)}</label></p>`).join("")}<button>${esc(button)}</button></form><script>document.querySelector('form').onsubmit=e=>{e.preventDefault();document.body.dataset.confirmed='yes'}</script></body></html>`,
|
||||
);
|
||||
await page.bringToFront();
|
||||
}
|
||||
async function confirm(page: Page, timeout: number) {
|
||||
await page.waitForFunction(
|
||||
() => document.body.dataset.confirmed === "yes",
|
||||
undefined,
|
||||
{ timeout },
|
||||
);
|
||||
}
|
||||
async function json<T>(
|
||||
c: BrowserContext,
|
||||
origin: string,
|
||||
pathname: string,
|
||||
): Promise<T> {
|
||||
const r = await c.request.get(origin + pathname);
|
||||
if (!r.ok()) throw new Error("Private named-client public read failed");
|
||||
return r.json() as Promise<T>;
|
||||
}
|
||||
async function main() {
|
||||
let attached = false;
|
||||
try {
|
||||
test.info();
|
||||
attached = true;
|
||||
} catch {
|
||||
/* standalone */
|
||||
}
|
||||
if (attached)
|
||||
throw new Error("Private named ChatGPT operation cannot run in Test");
|
||||
const filename = process.argv[2]!,
|
||||
directory = path.dirname(filename),
|
||||
stat = await lstat(filename);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== process.getuid?.() ||
|
||||
stat.mode & 0o077 ||
|
||||
stat.size > 2 * 1024 * 1024
|
||||
)
|
||||
throw new Error("Invalid private named-client file");
|
||||
const i = JSON.parse(await readFile(filename, "utf8")) as ChatgptInput;
|
||||
if (!validChatgptInput(i))
|
||||
throw new Error("Invalid finite named-client input");
|
||||
const origin = await readValidatedPublicOriginReceipt({
|
||||
receiptPath: i.receiptPath,
|
||||
executionId: i.executionId,
|
||||
attemptId: i.attemptId,
|
||||
backendOrigin: i.backendOrigin,
|
||||
});
|
||||
if (origin.publicOrigin !== i.publicOrigin)
|
||||
throw new Error("Actual owned public origin changed");
|
||||
const browser = await chromium.launch({
|
||||
headless: false,
|
||||
...(i.executablePath ? { executablePath: i.executablePath } : {}),
|
||||
...(i.channel ? { channel: i.channel } : {}),
|
||||
});
|
||||
const hardStop = setTimeout(() => {
|
||||
void browser.close();
|
||||
}, 1050000);
|
||||
hardStop.unref();
|
||||
let context: BrowserContext | undefined,
|
||||
appId: string | undefined,
|
||||
cleaned = false,
|
||||
cleanupGrantId: string | undefined,
|
||||
removeOwnedApp: (() => Promise<void>) | undefined,
|
||||
providerAppRemoved = false;
|
||||
const stage = async (value: string) =>
|
||||
writeFile(
|
||||
path.join(directory, "stage.json"),
|
||||
JSON.stringify({ stage: value }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
try {
|
||||
const stateFile = path.join(directory, "public-storage.json");
|
||||
await writeFile(stateFile, JSON.stringify(i.publicSession), {
|
||||
mode: 0o600,
|
||||
});
|
||||
context = await browser.newContext({ storageState: stateFile });
|
||||
context.setDefaultTimeout(20000);
|
||||
const person = await json<{ user: { id: string } }>(
|
||||
context,
|
||||
i.publicOrigin,
|
||||
"/api/auth/get-session",
|
||||
);
|
||||
if (person.user.id !== i.humanId)
|
||||
throw new Error(
|
||||
"Public human session does not match actual fixture person",
|
||||
);
|
||||
const operator = await context.newPage();
|
||||
const identity = `<p>Operator ${esc(i.operatorRef)}. Authorized synthetic ChatGPT account reference ${esc(i.authorizationRef)}. Company ${esc(i.companyName)} (${esc(i.companyId)}).</p>`;
|
||||
const common = {
|
||||
operation: i.operation,
|
||||
executionId: i.executionId,
|
||||
attemptId: i.attemptId,
|
||||
companyId: i.companyId,
|
||||
humanId: i.humanId,
|
||||
processIsolation: true,
|
||||
testRunnerAttached: false,
|
||||
tracingEnabled: false,
|
||||
videoEnabled: false,
|
||||
operatorAvailable: true,
|
||||
} as const;
|
||||
if (i.operation === "availability") {
|
||||
await stage("availability");
|
||||
await pane(
|
||||
operator,
|
||||
"Required ChatGPT operator availability",
|
||||
identity +
|
||||
"<p>No provider setup or consent has occurred. Confirm availability within 15 seconds.</p>",
|
||||
[
|
||||
"I am the explicitly selected authorized operator of this synthetic ChatGPT test account/workspace.",
|
||||
"I am available for this attempt, can create a private custom MCP app, and can remove that exact test app afterward.",
|
||||
],
|
||||
"Confirm availability",
|
||||
);
|
||||
await confirm(operator, 15000);
|
||||
await writeFile(
|
||||
path.join(directory, "qualified.json"),
|
||||
JSON.stringify(common),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
cleaned = true;
|
||||
return;
|
||||
}
|
||||
await stage("product-setup");
|
||||
const c = context,
|
||||
product = await c.newPage(),
|
||||
chatgpt = await c.newPage();
|
||||
await product.goto(
|
||||
`${i.publicOrigin}/${i.prefix}/apps/assistant-connection`,
|
||||
);
|
||||
await product.getByText("Set up manually", { exact: true }).click();
|
||||
await product
|
||||
.getByRole("tab", { name: "ChatGPT / Claude web", exact: true })
|
||||
.click();
|
||||
const setup = await json<McpConnectionSetup>(
|
||||
c,
|
||||
i.publicOrigin,
|
||||
"/api/mcp/setup",
|
||||
),
|
||||
url = new URL(setup.serverUrl);
|
||||
if (
|
||||
!setup.enabled ||
|
||||
url.origin !== i.publicOrigin ||
|
||||
url.pathname !== "/mcp/paperclip" ||
|
||||
url.search ||
|
||||
url.hash
|
||||
)
|
||||
throw new Error(
|
||||
"Actual named-client setup metadata differs from owned resource",
|
||||
);
|
||||
await product.getByText(setup.serverUrl, { exact: true }).first().waitFor();
|
||||
const beforeGrants = await json<McpConnection[]>(
|
||||
c,
|
||||
i.publicOrigin,
|
||||
"/api/mcp/connections",
|
||||
),
|
||||
beforeRuns = await json<Array<{ id: string }>>(
|
||||
c,
|
||||
i.publicOrigin,
|
||||
`/api/companies/${i.companyId}/heartbeat-runs?limit=100`,
|
||||
);
|
||||
if (beforeRuns.length >= 100)
|
||||
throw new Error("Cannot prove complete bounded company native inventory");
|
||||
const appName = `Paperclip ${i.prefix} setup ${i.attemptId.slice(0, 8)}`;
|
||||
removeOwnedApp = async () => {
|
||||
if (!appId || providerAppRemoved) return;
|
||||
let deleted = false;
|
||||
const observer = (r: import("@playwright/test").Response) => {
|
||||
const request = r.request(),
|
||||
u = new URL(r.url()),
|
||||
text = request.postData() ?? "";
|
||||
if (
|
||||
u.origin === "https://chatgpt.com" &&
|
||||
r.ok() &&
|
||||
(request.method() === "DELETE" ||
|
||||
(request.method() === "POST" &&
|
||||
/delete|remove/i.test(u.pathname))) &&
|
||||
(u.pathname.split("/").includes(appId!) || text.includes(appId!))
|
||||
)
|
||||
deleted = true;
|
||||
};
|
||||
c.on("response", observer);
|
||||
try {
|
||||
await stage("cleanup");
|
||||
await pane(
|
||||
operator,
|
||||
"Remove only this newly created ChatGPT test app",
|
||||
identity +
|
||||
`<p>In the actual ChatGPT Apps/Plugins settings, open <strong>${esc(appName)}</strong> and complete its Delete/Remove confirmation. Do not remove another app or conversation. Only a successful provider deletion response bound to this actual new app ID qualifies. Paperclip separately revokes only this test grant.</p>`,
|
||||
[],
|
||||
"I opened the exact new app's delete confirmation",
|
||||
);
|
||||
const deadline = Date.now() + 120000;
|
||||
while (Date.now() < deadline && !deleted)
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
if (!deleted)
|
||||
throw new Error(
|
||||
"Exact created ChatGPT app deletion was not observed",
|
||||
);
|
||||
providerAppRemoved = true;
|
||||
} finally {
|
||||
c.off("response", observer);
|
||||
}
|
||||
};
|
||||
// Observe only the real provider browser creation response; do not call any
|
||||
// ChatGPT API. The actual newly created ID scopes eventual UI cleanup.
|
||||
c.on("response", (response) => {
|
||||
void (async () => {
|
||||
const request = response.request(),
|
||||
u = new URL(response.url());
|
||||
if (
|
||||
u.origin !== "https://chatgpt.com" ||
|
||||
request.method() !== "POST" ||
|
||||
!response.ok()
|
||||
)
|
||||
return;
|
||||
const text = request.postData() ?? "";
|
||||
if (!text.includes(appName) || !text.includes(setup.serverUrl)) return;
|
||||
const actual = createdChatgptAppId(
|
||||
await response.json().catch(() => null),
|
||||
appName,
|
||||
text,
|
||||
setup.serverUrl,
|
||||
);
|
||||
if (actual) {
|
||||
if (appId && appId !== actual)
|
||||
throw new Error("More than one app created");
|
||||
appId = actual;
|
||||
}
|
||||
})().catch(() => {
|
||||
void browser.close();
|
||||
});
|
||||
});
|
||||
await chatgpt.goto("https://chatgpt.com/");
|
||||
await stage("provider-app");
|
||||
await pane(
|
||||
operator,
|
||||
"Create only the real private ChatGPT test app",
|
||||
identity +
|
||||
`<p>Use the other official ChatGPT tab. Sign in to the designated account, then open its current Apps/Create custom MCP settings (or its supported private custom-server flow). Do not publish anything to a workspace or adopt an existing app.</p><p>New app name: <strong>${esc(appName)}</strong></p><p>Use this exact URL copied from the real Paperclip browser setup instructions:</p><textarea readonly>${esc(setup.serverUrl)}</textarea><p>Choose OAuth. Stop at the actual Paperclip consent page and review the next private instruction panel. Do not paste any token, cookie, OAuth code or state into chat.</p>`,
|
||||
[],
|
||||
"I opened the actual provider flow",
|
||||
);
|
||||
let consent: Page | undefined,
|
||||
requestId = "";
|
||||
const deadline = Date.now() + 300000;
|
||||
while (Date.now() < deadline && !consent) {
|
||||
for (const p of c.pages()) {
|
||||
try {
|
||||
const u = new URL(p.url());
|
||||
if (
|
||||
u.origin === i.publicOrigin &&
|
||||
/^\/mcp-connect\/[A-Za-z0-9_-]+$/.test(u.pathname)
|
||||
) {
|
||||
consent = p;
|
||||
requestId = u.pathname.split("/").at(-1)!;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* private pane */
|
||||
}
|
||||
}
|
||||
if (!consent) await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
if (!consent || new URL(chatgpt.url()).origin !== "https://chatgpt.com")
|
||||
throw new Error(
|
||||
"Actual named ChatGPT UI did not produce its real Paperclip OAuth request",
|
||||
);
|
||||
await stage("consent");
|
||||
const request = await json<McpConnectionRequest>(
|
||||
c,
|
||||
i.publicOrigin,
|
||||
`/api/mcp/requests/${requestId}`,
|
||||
);
|
||||
if (!namedChatgptRequest(request, i.companyId))
|
||||
throw new Error(
|
||||
"Actual ChatGPT client/company/write request does not match qualification",
|
||||
);
|
||||
const configureDisclosed =
|
||||
!request.requestedConfigure ||
|
||||
(await consent
|
||||
.getByText(/configure agents, projects (?:and|&) skills/i)
|
||||
.isVisible());
|
||||
if (!configureDisclosed)
|
||||
throw new Error(
|
||||
"Actual requested configure authority is not visibly disclosed on Paperclip consent",
|
||||
);
|
||||
const submitted = consent.waitForResponse(
|
||||
(r) =>
|
||||
new URL(r.url()).origin === i.publicOrigin &&
|
||||
new URL(r.url()).pathname ===
|
||||
`/api/mcp/requests/${requestId}/consent` &&
|
||||
r.request().method() === "POST",
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
await pane(
|
||||
operator,
|
||||
"Review and approve the actual Paperclip consent",
|
||||
identity +
|
||||
`<p>On the real Paperclip consent tab, select this exact company and explicitly approve read and write access for the single controlled qualification below. ${request.requestedConfigure ? "Configuration authority is also requested: explicitly review the real visible Configure agents, projects and skills disclosure before accepting it for this isolated company." : "No configuration authority is requested or accepted."} Never approve agent/impersonation scope. Click Connect organization THERE, then finish creating the private ChatGPT app. This panel never approves consent.</p>`,
|
||||
[],
|
||||
"I reviewed the real consent tab",
|
||||
);
|
||||
await consent.bringToFront();
|
||||
const response = await submitted,
|
||||
payload = response.request().postDataJSON() as {
|
||||
decision?: string;
|
||||
companyId?: string;
|
||||
allowWrites?: boolean;
|
||||
allowConfiguration?: boolean;
|
||||
};
|
||||
if (
|
||||
!response.ok() ||
|
||||
payload.decision !== "approve" ||
|
||||
payload.companyId !== i.companyId ||
|
||||
payload.allowWrites !== true ||
|
||||
Boolean(payload.allowConfiguration) !==
|
||||
Boolean(request.requestedConfigure)
|
||||
)
|
||||
throw new Error(
|
||||
"Actual human consent did not explicitly approve the exact company/read-write scope",
|
||||
);
|
||||
const redirect = (await response.json()) as { redirectUrl?: string };
|
||||
if (
|
||||
!redirect.redirectUrl ||
|
||||
new URL(redirect.redirectUrl).origin !== "https://chatgpt.com"
|
||||
)
|
||||
throw new Error(
|
||||
"Actual provider callback target differs from named client",
|
||||
);
|
||||
await consent.waitForURL((u) => u.origin === "https://chatgpt.com", {
|
||||
timeout: 60000,
|
||||
});
|
||||
const grantDeadline = Date.now() + 60000;
|
||||
let grant: McpConnection | undefined;
|
||||
while (Date.now() < grantDeadline && !grant) {
|
||||
const rows = (
|
||||
await json<McpConnection[]>(c, i.publicOrigin, "/api/mcp/connections")
|
||||
).filter(
|
||||
(g) =>
|
||||
!beforeGrants.some((old) => old.id === g.id) &&
|
||||
g.companyId === i.companyId &&
|
||||
!g.revokedAt,
|
||||
);
|
||||
if (rows.length > 1) throw new Error("Duplicate new assistant grants");
|
||||
grant = rows[0];
|
||||
if (!grant) await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
if (grant) cleanupGrantId = grant.id;
|
||||
if (
|
||||
!grant ||
|
||||
!/^(?:ChatGPT|OpenAI|OpenAI ChatGPT)$/i.test(grant.clientName) ||
|
||||
!grant.scopes.includes("paperclip:read") ||
|
||||
!grant.scopes.includes("paperclip:write") ||
|
||||
!exactAcceptedChatgptScopes(
|
||||
[
|
||||
"paperclip:read",
|
||||
"paperclip:write",
|
||||
...(request.requestedConfigure ? ["paperclip:configure"] : []),
|
||||
...(request.offlineAccess ? ["offline_access"] : []),
|
||||
],
|
||||
grant.scopes,
|
||||
configureDisclosed,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Actual named ChatGPT grant does not preserve explicitly accepted access",
|
||||
);
|
||||
cleanupGrantId = grant.id;
|
||||
const qualify = `Using only the newly connected Paperclip app, read task ${i.control!.identifier} in ${i.companyName}. Then add exactly one neutral comment to that same task: “Setup check: the launch owner is [the actual launch owner from the task description].” Replace the bracketed phrase with the observed owner. Do not assign, start, modify, mention anyone, or comment on any other task. Do not repeat the write with a different requestId. Return the real comment link.`;
|
||||
await stage("qualification");
|
||||
await pane(
|
||||
operator,
|
||||
"Use only the newly connected ChatGPT app",
|
||||
identity +
|
||||
`<p>Finish the app's actual Create step if still pending. Select this exact app in a real ChatGPT conversation and send this one bounded instruction. Approve only its one comment when ChatGPT asks. No separate REST client or manual Paperclip comment is permitted.</p><textarea readonly rows="8">${esc(qualify)}</textarea>`,
|
||||
[],
|
||||
"I sent the limited instruction to the real ChatGPT app",
|
||||
);
|
||||
let comment:
|
||||
| {
|
||||
id: string;
|
||||
authorUserId: string;
|
||||
authorAgentId?: string | null;
|
||||
createdByRunId?: string | null;
|
||||
clientRequestId?: string;
|
||||
body: string;
|
||||
}
|
||||
| undefined;
|
||||
const qualifyDeadline = Date.now() + 180000;
|
||||
while (Date.now() < qualifyDeadline && !comment) {
|
||||
const comments = await json<NonNullable<typeof comment>[]>(
|
||||
c,
|
||||
i.publicOrigin,
|
||||
`/api/issues/${i.control!.id}/comments?order=asc`,
|
||||
);
|
||||
if (comments.length > 1)
|
||||
throw new Error("ChatGPT qualification duplicated the control comment");
|
||||
if (comments.length === 1) comment = comments[0];
|
||||
if (!comment) await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
if (
|
||||
!comment ||
|
||||
comment.authorUserId !== i.humanId ||
|
||||
comment.authorAgentId ||
|
||||
comment.createdByRunId ||
|
||||
!comment.clientRequestId ||
|
||||
!setupCheckComment(comment.body, i.control!.ownerFact)
|
||||
)
|
||||
throw new Error(
|
||||
"Actual real-client control comment is missing, incorrectly attributed or not grounded in the task read",
|
||||
);
|
||||
const events = await json<
|
||||
Array<{
|
||||
action: string;
|
||||
actorId: string;
|
||||
actorType: string;
|
||||
entityId: string;
|
||||
details?: Record<string, unknown>;
|
||||
}>
|
||||
>(c, i.publicOrigin, `/api/companies/${i.companyId}/activity?limit=1000`);
|
||||
const tool = events.filter(
|
||||
(e) => e.action === "mcp.tool_called" && e.entityId === grant!.id,
|
||||
);
|
||||
if (
|
||||
tool.length !== 1 ||
|
||||
tool[0]!.actorType !== "user" ||
|
||||
tool[0]!.actorId !== i.humanId ||
|
||||
tool[0]!.details?.operation !== "paperclip_add_comment" ||
|
||||
tool[0]!.details?.requestId !== comment.clientRequestId
|
||||
)
|
||||
throw new Error(
|
||||
"Actual named-client grant audit does not bind the exact human control comment",
|
||||
);
|
||||
const afterRuns = await json<Array<{ id: string }>>(
|
||||
c,
|
||||
i.publicOrigin,
|
||||
`/api/companies/${i.companyId}/heartbeat-runs?limit=100`,
|
||||
);
|
||||
if (
|
||||
afterRuns.length >= 100 ||
|
||||
JSON.stringify(afterRuns.map((r) => r.id).sort()) !==
|
||||
JSON.stringify(beforeRuns.map((r) => r.id).sort())
|
||||
)
|
||||
throw new Error(
|
||||
"ChatGPT setup qualification admitted undeclared native work",
|
||||
);
|
||||
if (
|
||||
(
|
||||
await json<{ user: { id: string } }>(
|
||||
c,
|
||||
i.publicOrigin,
|
||||
"/api/auth/get-session",
|
||||
)
|
||||
).user.id !== i.humanId
|
||||
)
|
||||
throw new Error("Actual consenting person changed");
|
||||
if (!appId)
|
||||
throw new Error(
|
||||
"Actual provider app creation ID was not inspectable for scoped cleanup",
|
||||
);
|
||||
cleanupGrantId = grant.id;
|
||||
const receipt: ChatgptReceipt = {
|
||||
...common,
|
||||
grantId: grant.id,
|
||||
requestId,
|
||||
commentId: comment.id,
|
||||
clientRequestId: comment.clientRequestId,
|
||||
actualChatgptUi: true,
|
||||
realHumanConsent: true,
|
||||
actualProviderCallback: true,
|
||||
boundMcpComment: true,
|
||||
zeroNativeAdmissions: true,
|
||||
requestedScopes: [
|
||||
"paperclip:read",
|
||||
"paperclip:write",
|
||||
...(request.requestedConfigure ? ["paperclip:configure"] : []),
|
||||
...(request.offlineAccess ? ["offline_access"] : []),
|
||||
],
|
||||
acceptedScopes: [...grant.scopes],
|
||||
configureDisclosed,
|
||||
};
|
||||
await writeFile(
|
||||
path.join(directory, "qualified.json"),
|
||||
JSON.stringify(receipt),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await stage("awaiting-cleanup");
|
||||
let cleanup = false;
|
||||
const cleanupDeadline = Date.now() + 90000;
|
||||
while (Date.now() < cleanupDeadline && !cleanup) {
|
||||
try {
|
||||
const gate = JSON.parse(
|
||||
await readFile(path.join(directory, "cleanup.json"), "utf8"),
|
||||
);
|
||||
cleanup =
|
||||
gate.operation === "cleanup" &&
|
||||
gate.attemptId === i.attemptId &&
|
||||
gate.grantId === grant.id;
|
||||
} catch {
|
||||
/* exact private gate not present */
|
||||
}
|
||||
if (!cleanup) await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!cleanup)
|
||||
throw new Error("Exact private cleanup gate was not released");
|
||||
await removeOwnedApp();
|
||||
cleaned = true;
|
||||
} finally {
|
||||
// A failed qualification can still have created a real provider app. Keep
|
||||
// its exact UI cleanup available, without claiming qualification succeeded.
|
||||
if (appId && !providerAppRemoved)
|
||||
await removeOwnedApp?.().catch(() => undefined);
|
||||
if (!cleaned && cleanupGrantId && context) {
|
||||
const revoked = await context.request
|
||||
.delete(`${i.publicOrigin}/api/mcp/connections/${cleanupGrantId}`)
|
||||
.catch(() => undefined);
|
||||
if (!revoked?.ok())
|
||||
await writeFile(
|
||||
path.join(directory, "grant-cleanup-incomplete.json"),
|
||||
JSON.stringify({ grantCleanupComplete: false }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
clearTimeout(hardStop);
|
||||
try {
|
||||
await context?.clearCookies();
|
||||
} finally {
|
||||
try {
|
||||
await context?.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
if (cleaned && cleanupGrantId && providerAppRemoved)
|
||||
await writeFile(
|
||||
path.join(directory, "cleanup-complete.json"),
|
||||
JSON.stringify({
|
||||
attemptId: i.attemptId,
|
||||
grantId: cleanupGrantId,
|
||||
providerAppRemoved: true,
|
||||
privateBrowserClosed: true,
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
if (!cleaned)
|
||||
await writeFile(
|
||||
path.join(directory, "incomplete.json"),
|
||||
JSON.stringify({ cleanupComplete: false }),
|
||||
{ mode: 0o600 },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
main().catch(() => {
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { mkdtemp, writeFile, readFile, chmod, rm } from "node:fs/promises";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "@playwright/test";
|
||||
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
||||
import { signInPublicFixturePerson } from "../public-mcp-flow.js";
|
||||
import {
|
||||
readValidatedPublicOriginReceipt,
|
||||
PUBLIC_ORIGIN_RECEIPT_ENV,
|
||||
} from "../chat-public-origin.js";
|
||||
import { selectedRuntimeInputs } from "./runtime-private.js";
|
||||
import {
|
||||
validChatgptInput,
|
||||
validChatgptReceipt,
|
||||
type ChatgptInput,
|
||||
type ChatgptReceipt,
|
||||
} from "./external-chatgpt-contract.js";
|
||||
interface Custody {
|
||||
directory: string;
|
||||
child: ChildProcess;
|
||||
exited: Promise<number | null>;
|
||||
exitCode: number | null | undefined;
|
||||
receipt?: ChatgptReceipt;
|
||||
}
|
||||
const owned = new WeakMap<ChatCatalogContext, Custody>();
|
||||
export async function chatgptPublicOrigin(ctx: ChatCatalogContext) {
|
||||
const receiptPath = process.env[PUBLIC_ORIGIN_RECEIPT_ENV],
|
||||
attemptId = process.env.PAPERCLIP_E2E_ACCOUNTING_ATTEMPT_ID;
|
||||
if (!receiptPath || !attemptId)
|
||||
throw new Error(
|
||||
"Setup blocker: actual named ChatGPT setup requires its selected owned HTTPS-origin receipt",
|
||||
);
|
||||
return readValidatedPublicOriginReceipt({
|
||||
receiptPath,
|
||||
attemptId,
|
||||
executionId: ctx.input.execution.id,
|
||||
backendOrigin: ctx.input.api.baseURL,
|
||||
});
|
||||
}
|
||||
async function inputFor(
|
||||
ctx: ChatCatalogContext,
|
||||
operation: ChatgptInput["operation"],
|
||||
control?: ChatgptInput["control"],
|
||||
): Promise<ChatgptInput> {
|
||||
const selected = selectedRuntimeInputs(
|
||||
ctx,
|
||||
"assistant-connections.connect-external-assistant",
|
||||
),
|
||||
p = selected.values,
|
||||
secrets = ctx.input.privateSecrets;
|
||||
if (
|
||||
!secrets ||
|
||||
p.operatorMode !== "interactive-private-ui" ||
|
||||
!selected.resourceIds.includes(p.chatgptAuthorizationRef)
|
||||
)
|
||||
throw new Error(
|
||||
"Setup blocker: explicitly selected available operator and authorized synthetic ChatGPT account reference are required; no ambient account/session is inferred",
|
||||
);
|
||||
secrets.push(p.operatorRef, p.chatgptAuthorizationRef);
|
||||
await signInPublicFixturePerson(ctx.input.api, ctx.input.page.context());
|
||||
const origin = await chatgptPublicOrigin(ctx),
|
||||
person = await ctx.input.api.get<{ user: { id: string } }>(
|
||||
"/api/auth/get-session",
|
||||
),
|
||||
state = await ctx.input.page.context().storageState(),
|
||||
host = new URL(origin.publicOrigin).hostname;
|
||||
const publicSession = {
|
||||
cookies: state.cookies.filter((c) => c.domain.replace(/^\./, "") === host),
|
||||
origins: state.origins.filter((o) => o.origin === origin.publicOrigin),
|
||||
};
|
||||
for (const c of publicSession.cookies) secrets.push(c.value);
|
||||
for (const o of publicSession.origins)
|
||||
for (const item of o.localStorage) secrets.push(item.value);
|
||||
const channel = process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL;
|
||||
const input: ChatgptInput = {
|
||||
operation,
|
||||
executionId: ctx.input.execution.id,
|
||||
attemptId: origin.attemptId,
|
||||
receiptPath: process.env[PUBLIC_ORIGIN_RECEIPT_ENV]!,
|
||||
backendOrigin: ctx.input.api.baseURL,
|
||||
publicOrigin: origin.publicOrigin,
|
||||
companyId: ctx.input.fixtures.company.id,
|
||||
companyName: ctx.input.fixtures.company.name,
|
||||
prefix: ctx.input.fixtures.company.issuePrefix ?? "",
|
||||
humanId: person.user.id,
|
||||
operatorRef: p.operatorRef,
|
||||
authorizationRef: p.chatgptAuthorizationRef,
|
||||
publicSession,
|
||||
control,
|
||||
executablePath:
|
||||
process.env.PAPERCLIP_RUNNER_E2E_CHROMIUM_EXECUTABLE ??
|
||||
(channel ? undefined : chromium.executablePath()),
|
||||
channel,
|
||||
};
|
||||
if (!validChatgptInput(input))
|
||||
throw new Error(
|
||||
"Setup blocker: named ChatGPT operator/public-person/control contract is invalid",
|
||||
);
|
||||
return input;
|
||||
}
|
||||
async function stop(c: Custody) {
|
||||
if (c.exitCode === undefined) c.child.kill("SIGTERM");
|
||||
await Promise.race([c.exited, new Promise((r) => setTimeout(r, 5000))]);
|
||||
if (c.exitCode === undefined) c.child.kill("SIGKILL");
|
||||
await c.exited;
|
||||
}
|
||||
async function stage(c: Custody) {
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
await readFile(path.join(c.directory, "stage.json"), "utf8"),
|
||||
) as { stage?: string };
|
||||
return [
|
||||
"availability",
|
||||
"product-setup",
|
||||
"provider-app",
|
||||
"consent",
|
||||
"qualification",
|
||||
"awaiting-cleanup",
|
||||
"cleanup",
|
||||
].includes(value.stage ?? "")
|
||||
? value.stage
|
||||
: "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
async function start(ctx: ChatCatalogContext, input: ChatgptInput) {
|
||||
if (owned.has(ctx))
|
||||
throw new Error(
|
||||
"A named ChatGPT private child is already owned by this case",
|
||||
);
|
||||
const directory = await mkdtemp(
|
||||
path.join(tmpdir(), "paperclip-chatgpt-operator-"),
|
||||
);
|
||||
await chmod(directory, 0o700);
|
||||
try {
|
||||
const filename = path.join(directory, "request.json");
|
||||
await writeFile(filename, JSON.stringify(input), {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../server/node_modules/tsx/dist/loader.mjs",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
fileURLToPath(new URL("./external-chatgpt-worker.ts", import.meta.url)),
|
||||
filename,
|
||||
],
|
||||
{
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: directory,
|
||||
TMPDIR: directory,
|
||||
NODE_ENV: "test",
|
||||
DO_NOT_TRACK: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
let resolve!: (code: number | null) => void;
|
||||
const exited = new Promise<number | null>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
const c: Custody = { directory, child, exited, exitCode: undefined };
|
||||
owned.set(ctx, c);
|
||||
child.once("error", () => {
|
||||
c.exitCode = 1;
|
||||
resolve(1);
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
c.exitCode = code;
|
||||
resolve(code);
|
||||
});
|
||||
const deadline =
|
||||
Date.now() + (input.operation === "availability" ? 45000 : 900000);
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
await readFile(path.join(directory, "qualified.json"), "utf8"),
|
||||
) as ChatgptReceipt;
|
||||
if (!validChatgptReceipt(value, input))
|
||||
throw new Error(
|
||||
"Closed named ChatGPT receipt failed identity/isolation contract",
|
||||
);
|
||||
c.receipt = value;
|
||||
if (input.operation === "availability") {
|
||||
const code = await c.exited;
|
||||
if (code !== 0)
|
||||
throw new Error(
|
||||
"Named ChatGPT availability child did not close cleanly",
|
||||
);
|
||||
owned.delete(ctx);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
if (c.exitCode !== undefined)
|
||||
throw new Error(
|
||||
`Named ChatGPT human operation failed at ${await stage(c)}; private account/OAuth/browser diagnostics withheld`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
throw new Error(
|
||||
`Setup blocker: required named ChatGPT human operation exceeded its bounded ${await stage(c)} phase`,
|
||||
);
|
||||
} catch (error) {
|
||||
const c = owned.get(ctx);
|
||||
owned.delete(ctx);
|
||||
if (c) {
|
||||
// A failed private operation may still be removing its exact new app.
|
||||
// Allow only its finite existing cleanup phase before termination.
|
||||
if ((await stage(c)) === "cleanup")
|
||||
await Promise.race([
|
||||
c.exited,
|
||||
new Promise((r) => setTimeout(r, 125000)),
|
||||
]);
|
||||
await stop(c);
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function privateChatgptAvailability(ctx: ChatCatalogContext) {
|
||||
return start(ctx, await inputFor(ctx, "availability"));
|
||||
}
|
||||
export async function privateChatgptSetup(
|
||||
ctx: ChatCatalogContext,
|
||||
control: NonNullable<ChatgptInput["control"]>,
|
||||
) {
|
||||
return start(ctx, await inputFor(ctx, "setup", control));
|
||||
}
|
||||
/** Only this fixed cleanup gate is sent to the already-owned child; no generic
|
||||
* execute payload, provider token, browser state or arbitrary URL is accepted. */
|
||||
export async function cleanupPrivateChatgpt(ctx: ChatCatalogContext) {
|
||||
const c = owned.get(ctx);
|
||||
owned.delete(ctx);
|
||||
if (!c) return;
|
||||
try {
|
||||
if (!c.receipt?.grantId)
|
||||
throw new Error(
|
||||
"Actual named ChatGPT setup never reached its closed qualification receipt",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(c.directory, "cleanup.json"),
|
||||
JSON.stringify({
|
||||
operation: "cleanup",
|
||||
attemptId: c.receipt.attemptId,
|
||||
grantId: c.receipt.grantId,
|
||||
}),
|
||||
{ mode: 0o600, flag: "wx" },
|
||||
);
|
||||
const timeout = new Promise<"timeout">((r) => {
|
||||
const t = setTimeout(() => r("timeout"), 135000);
|
||||
t.unref();
|
||||
});
|
||||
const code = await Promise.race([c.exited, timeout]);
|
||||
if (code !== 0)
|
||||
throw new Error(
|
||||
"Named ChatGPT cleanup did not finish; selected operator must inspect the exact newly created test app",
|
||||
);
|
||||
const receipt = JSON.parse(
|
||||
await readFile(path.join(c.directory, "cleanup-complete.json"), "utf8"),
|
||||
) as {
|
||||
attemptId: string;
|
||||
grantId: string;
|
||||
providerAppRemoved: boolean;
|
||||
privateBrowserClosed: boolean;
|
||||
};
|
||||
if (
|
||||
Object.keys(receipt).sort().join(",") !==
|
||||
"attemptId,grantId,privateBrowserClosed,providerAppRemoved" ||
|
||||
receipt.attemptId !== c.receipt.attemptId ||
|
||||
receipt.grantId !== c.receipt.grantId ||
|
||||
receipt.providerAppRemoved !== true ||
|
||||
receipt.privateBrowserClosed !== true
|
||||
)
|
||||
throw new Error(
|
||||
"Named ChatGPT cleanup lacks its exact closed actual UI deletion receipt",
|
||||
);
|
||||
} finally {
|
||||
await stop(c);
|
||||
await rm(c.directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -321,3 +321,36 @@ operated on an existing credential-bearing shared account, rather than claiming
|
||||
a new credential-free personal draft. The finite case declares four native runs,
|
||||
two actual human queries and a 25-minute attempt ceiling. Missing qualified
|
||||
credential/session/origin stays separate from authored callback completeness.
|
||||
|
||||
`assistant-connections.connect-external-assistant` uses selected runtime provider
|
||||
`chatgpt` with only `operatorRef`, `chatgptAuthorizationRef`, and
|
||||
`operatorMode=interactive-private-ui`. The authorization reference must be in the
|
||||
selected disposable binding's `resourceIds`. These are private reference names,
|
||||
not a ChatGPT API key, browser-cookie import, or authority inferred from an
|
||||
ambient account. It also requires the exact attempt's validated owned HTTPS
|
||||
origin and genuine public sign-in of the same generated Paperclip fixture person.
|
||||
|
||||
The finite standalone headed browser first asks the designated human to confirm
|
||||
availability. After the unchanged canonical CEO query, that person actually uses
|
||||
ChatGPT's supported private custom MCP app UI and the real Paperclip consent tab.
|
||||
The child observes the actual named-client request, official ChatGPT callback,
|
||||
explicit company and scope acceptance, and one new saved grant. Configuration
|
||||
scope is permitted only when requested, visibly disclosed in the real consent,
|
||||
and explicitly accepted; agent authority is excluded. The current source's
|
||||
write-only consent label does not itself establish configuration disclosure.
|
||||
Changed or missing controls and failed provider authorization are nonpassing
|
||||
observations, never grounds for a generic-client substitution.
|
||||
|
||||
The only qualification mutation is the approved one neutral setup-check comment
|
||||
on a newly owned human-controlled backlog task. A real ChatGPT tool read supplies
|
||||
the substantive synthetic owner fact; its one actual MCP comment must join the
|
||||
same grant/person/client/request ID in public audit. No agent assignment,
|
||||
watchdog, mention, REST comment surrogate or new company native run is allowed.
|
||||
Actual provider browser creation identifies the unique new private app for
|
||||
subsequent human UI deletion; a successful provider response bound to that exact
|
||||
app ID is required. The Paperclip grant and owned control task are separately
|
||||
removed through public APIs. Private provider state remains in the child and is
|
||||
never exported. The case declares one canonical human message/native run and a
|
||||
25-minute attempt ceiling; human setup, provider callback, qualification and
|
||||
cleanup each have bounded waits. Missing operator/account/origin remains a
|
||||
concrete prerequisite, distinct from callback completeness and measured success.
|
||||
@@ -19,8 +19,9 @@ import { connectionSetupExternalCases } from "./external-connection-setup.js";
|
||||
import { externalPrerequisiteReceipt, externalRequirements, validateExternalConfig, selectedExternalBinding, selectedExternalCredential } from "./external-fixtures.js";
|
||||
import type { ExternalObservation } from "./external-support.js";
|
||||
|
||||
// The multi-phase review has dedicated controls in external-app-review.test.ts.
|
||||
const definitions = [...appPermissionExternalCases.filter(row => row.scenarioId !== "app-permissions.ask-before-action"), ...teamExternalCases, ...secretExternalCases, ...gatewayExternalCases, ...connectionSetupExternalCases, ...assistantConnectionExternalCases, ...chatChannelExternalCases, ...appCatalogExternalCases, ...pluginExternalCases, ...assistantDeviceExternalCases, ...gatewayClientExternalCases, ...pluginInstallExternalCases, ...mcpFailureExternalCases, ...assistantConsentExternalCases, ...notionReadExternalCases, ...secureKeyExternalCases];
|
||||
// The multi-phase review and actual named ChatGPT setup have dedicated controls
|
||||
// in external-app-review.test.ts and external-chatgpt-setup.test.ts.
|
||||
const definitions = [...appPermissionExternalCases.filter(row => row.scenarioId !== "app-permissions.ask-before-action"), ...teamExternalCases, ...secretExternalCases, ...gatewayExternalCases, ...connectionSetupExternalCases, ...assistantConnectionExternalCases.filter(row => row.scenarioId !== "assistant-connections.connect-external-assistant"), ...chatChannelExternalCases, ...appCatalogExternalCases, ...pluginExternalCases, ...assistantDeviceExternalCases, ...gatewayClientExternalCases, ...pluginInstallExternalCases, ...mcpFailureExternalCases, ...assistantConsentExternalCases, ...notionReadExternalCases, ...secureKeyExternalCases];
|
||||
function base(scenarioId: string): ExternalObservation {
|
||||
return { scenarioId, origin: "http://fixture.invalid", companyId: "company", leadId: "ceo", state: { initialRunId: "initial" },
|
||||
phaseReplies: [], connections: [], profiles: [], secrets: [], endpoints: [], interactions: [], tasks: [], projects: [], goals: [], comments: [],
|
||||
@@ -101,10 +102,6 @@ function valid(id: string) {
|
||||
o.reply = "I could not read Notion because you declined access. You can paste an excerpt instead.";
|
||||
} else if (id === "chat-channels.provider-disabled") {
|
||||
Object.assign(o.state, { disabledBefore: true, disabledAfter: true, imessageSetupGated: true }); o.reply = "iMessage is not enabled. An instance administrator can enable experimental chat connectors.";
|
||||
} else if (id === "assistant-connections.connect-external-assistant") {
|
||||
Object.assign(o.state, { assistantConnections: [{ id: "grant", companyId: "company", scopes: ["paperclip:read"] }],
|
||||
humanConsentCompleted: true, clientCompanyId: "company", clientReadScope: true, directRestStatus: 401, unauthenticatedMcpStatus: 401,
|
||||
resultPath: "/ACORN/apps/assistant-connection" }); o.links = ["/ACORN/apps/assistant-connection"];
|
||||
} else if (id === "assistant-connections.decline-consent") {
|
||||
Object.assign(o.state, { consentVisible: true, callbackError: "access_denied", callbackStateMatched: true, newGrantCount: 0,
|
||||
otherGrantBefore: "retained unrelated grant", otherGrantAfter: "retained unrelated grant", otherGrantStillUsable: true });
|
||||
@@ -131,7 +128,6 @@ const wrong: Record<string, (o: ExternalObservation) => void> = {
|
||||
"secrets.ask-for-plaintext": o => { o.state.secretLeaked = true; },
|
||||
"gateways-profiles.create-gateway": o => { (o.state.gatewaysAfter as Array<{ companyId: string }>)[0]!.companyId = "other"; },
|
||||
"chat-channels.provider-disabled": o => { o.state.disabledAfter = false; },
|
||||
"assistant-connections.connect-external-assistant": o => { o.state.clientCompanyId = "other"; },
|
||||
"assistant-connections.decline-consent": o => { o.state.otherGrantStillUsable = false; },
|
||||
"connection-setup.notion-declined": o => { o.interactions[0]!.resolvedByUserId = "someone-else"; },
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user