mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 01:52:55 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Tool gateways let external MCP clients use a company's tools with a
gateway client token
> - Since delegated connection management (#17737), an agent can create
and revoke gateway client tokens for its responsible person
> - #20581 stopped agents from getting the one-time token value, but it
did this by making token creation person-only
> - The product decision is that agents keep their delegated authority
to create and revoke tokens
> - This pull request restores agent token creation, but the agent never
receives the token value: the person reveals the value once on the
gateway's Tokens page
> - The benefit is that an agent can do the rotation work for the
person, and the secret still never enters chat, run events, logs or tool
output
## Linked Issues or Issue Description
Refs #17737
Refs #20581
**What happened**
After #20581, `POST /api/tool-gateway/gateways/{gatewayId}/tokens`
returns 403 `gateway_token_requires_person` to every agent caller.
Agents lost the delegated authority to create gateway client tokens that
#17737 gave them. Before #20581, an agent that created a token received
the plaintext value in its tool result and could repeat it in chat.
**Expected behavior**
An agent with delegated connection management can create and revoke
gateway client tokens. The agent never receives a token value. The
responsible person gets the value once, on the gateway's Tokens page.
Person callers keep today's behavior.
**Steps to reproduce**
1. Give an agent delegated connection management for a responsible
person with `tools:admin`.
2. As the agent, call `POST
/api/tool-gateway/gateways/{gatewayId}/tokens` (for example through
`call_api`).
3. See 403 `gateway_token_requires_person`.
## What Changed
- Agent create: `POST /api/tool-gateway/gateways/:gatewayId/tokens` from
an agent with delegated authority returns 201 with token metadata only
(`id`, `tokenPrefix`, `expiresAt`, `pendingReveal: true`), plus
`tokensPath`, a `reveal_credential` `requiredUserAction` for the
responsible person, and a `nextStep` instruction. The saved row has no
usable value: its stored hash is of a value that is generated and then
discarded. Gateway authentication also rejects `pendingReveal` rows.
- Person reveal: new person-only `POST
/api/tool-gateway/gateway-tokens/:tokenId/reveal`. It generates the
value, stores its hash, clears `pendingReveal` and returns the value
once. A conditional update makes a second or concurrent reveal return
409. Only the token's responsible person can reveal it (403
`gateway_token_reveal_requires_responsible_person`). Agent callers get
403 `gateway_token_requires_person`. A token revoked before reveal
cannot be revealed.
- Why a reveal that generates the value: no stored plaintext existed to
reveal, and storing one (even encrypted) for later display adds a secret
at rest. Generating the value at reveal time keeps the existing one-time
display and stores only a hash.
- Schema: `tool_mcp_gateway_tokens.pending_reveal boolean not null
default false` (migration `0460_gateway_token_pending_reveal`). Shared
`ToolMcpGatewayToken.pendingReveal`, and `ConnectionRequiredUserAction`
gets `reveal_credential` / `gateway_token`.
- UI: the Tokens page shows a "Waiting for you to reveal" section for
pending tokens with a "Reveal token" button. The value appears in the
existing "New token — copy now" one-time block. Pending tokens show the
status "Not revealed".
- Agent gateway create: the 201 for agent callers also includes
`gatewayPath`, `tokensPath` and a `nextStep` that tells the agent to
link the saved gateway.
- The agent create-token `nextStep` and the catalog say not to revoke
the old token in the same turn: its client keeps working on it until the
person installs the new value.
- The reveal update and its `tool_gateway.token_revealed` activity entry
commit in one transaction. The Tokens page offers Reveal only for tokens
that act for the signed-in person.
- API reference: new `docs/api/tool-gateway-tokens.md` (create, reveal,
revoke) in the docs nav.
- Revoke stays allowed for agents with delegated authority (unchanged
route).
- API catalog: create-gateway, create-token, reveal and revoke
descriptions now say who acts, that agents never receive values, the
page paths, and that the old token is revoked only after the client
works with the new one.
- Agent access map: create-token policy note and a new reveal entry.
- Evals `gateways-profiles.create-gateway` and
`gateways-profiles.rotate-token`:
- Remove the keyword grader (`human|operator|admin|manage`) and the
handoff text checks. Grade saved state, rendered links and secret
absence. A plain reply or a question card is accepted.
- Remove the setup probes that called the create routes as the agent.
Under delegated authority, those probes created a real gateway
("Forbidden agent gateway"), which failed create-gateway before the
agent ran.
- create-gateway: exactly one gateway for the intended profile and
company, created by the agent (or by the person after a handoff); the
person opens it from a rendered link; attribution matches the creator;
any agent-created token is still pending reveal; no token value in chat,
cards or run events.
- rotate-token: after the agent's turn, both setup tokens still work and
any replacement the agent created is pending the person's reveal; the
person follows the rendered Tokens page link and reveals (or issues) the
value in a private browser; the new value works before the old token is
revoked; the unrelated client keeps access; no token value appears
anywhere, including a regex scan for any full `pcgw_<id>.<secret>`
value.
- The changed scenario context and outcomes are recorded in
`chat-full-catalog-testops-manifest.json` `changedFields` and pinned in
the manifest test. The inventory digest, descriptors (`oracleVersion`
v2, `sourceFiles`) and SDK public input pins are updated. The unused
`external-gateway-handoff.ts` module is removed.
- Fixture realism: the create-gateway profile now allows a real read
tool (an empty profile made the agent ask which apps to add instead of
finishing), and the rotation tokens get a neutral owner note instead of
"Disposable rotation fixture" (the agent read that note as an eval
hint).
- Shared private-browser helpers (`external-private-browser.ts`,
`external-private-browser-worker.ts`) get one optional `pendingTokenId`
input and one `revealedPending` receipt field.
## Verification
- Red: on `master` route code, the new integration tests `lets agents
create and revoke gateway client tokens without ever receiving a token
value` and `reveals an agent-created gateway token value only to its
responsible person` fail (403 `gateway_token_requires_person`).
- Green: `cd server && npx vitest run
src/__tests__/connection-management.integration.test.ts -t gateway` (25
passed). The test checks that the agent `call_api` result has no `token`
field and no token-shaped value, that the agent cannot reveal, that the
person reveals once (second reveal 409) and the value authenticates,
that agent revoke works and stops the value, that a token revoked before
reveal never gets a value, and that person create still returns the
value.
- `npx vitest run src/__tests__/tool-gateway.test.ts
src/__tests__/openapi-routes.test.ts
src/services/native-runtime/runner-api.test.ts` (2247 passed).
- `cd ui && npx vitest run
src/pages/apps/gateways/panels/TokensPanel.test.tsx
src/pages/apps/gateways/gateway-helpers.test.ts` (16 passed); `pnpm
check:token-gates` clean.
- Eval graders and catalog: `pnpm exec vitest run --config
tests/runner-e2e/vitest.config.ts` on the gateway oracle tests,
`external.test.ts`, catalog consistency/contract, TestOps
export/retained, phase/clock callers and SDK input/type tests (all
pass); `node --test` on the TestOps manifest, adapter, contract,
streaming and reconcile tests (all pass); `node
tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`
(unchanged).
- `node scripts/check-maps.mjs` passes. `tsc --noEmit` for `server`,
`ui` and `tests/runner-e2e`: no errors in changed files.
- Paid end-to-end run of both eval cases, run
[42872921890](https://github.com/paperclipai/paperclip/actions/runs/42872921890)
on 39f38a7f6c (before the last three commits): both FAILED, for
agent-behavior reasons the later commits address. Not rerun yet.
- rotate-token: the agent created the replacement (pending reveal, no
value), then revoked the old token in the same turn, despite the create
response saying to wait. Its reasoning cited the eval-looking fixture
note. The grader rejected the premature revocation. The reply linked the
Tokens page correctly.
- create-gateway: the agent created the gateway and one pending token,
then asked a question card about which apps to add to the empty profile,
with no link to the gateway. The grader rejected the missing link.
- Token values: a scan of all evidence files of both cells found no full
`pcgw_<id>.<secret>` value and no `pcgw_<uuid>` form. Agent `call_api`
payloads in run events are redacted; the integration test proves the
agent tool result has no value.
## Risks
- Behavior change for agents: token create returns metadata and a Tokens
page path, not a value. Agents that expect `token` in the response must
send the person to the Tokens page.
- Migration adds one boolean column with a default. Existing tokens get
`false` and keep working.
- A pending token that is never revealed stays as an unusable row until
someone revokes it. It cannot authenticate.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution in Claude Code.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
37 KiB
TypeScript
312 lines
37 KiB
TypeScript
import {canonicalCompanySecretKey,validCompanySecretMetadata} from "./external-company-secret-confirmation.js";
|
|
import { personalSecretEntryDialog } from "./external-personal-secret-entry-dialog.js";
|
|
import { validDevicePrivateInput, type DevicePrivateInput } from "./external-device-private-contract.js";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { chromium, test, type Cookie } from "@playwright/test";
|
|
import { validGrokRoutinePrivateInput, matchesConfiguredGrokRoutine, type GrokRoutinePrivateInput } from "./external-grok-private-contract.js";
|
|
import { validSlackIdentityInput, validSlackWizardIdentityInput, matchingSlackIdentityPreview, type SlackIdentityPrivateInput } from "./external-slack-identity-contract.js";
|
|
import { validSlackSetupPrivate, type SlackSetupPrivateInput } from "./external-slack-setup-private-contract.js";
|
|
interface Request { mode: "secure-key" | "canary" | "gateway-token-rotation" | "company-secret-create" | "personal-secret-create" | "grok-routine-configure" | "slack-identity-confirm" | "slack-endpoint-configure" | "slack-wizard-confirm" | "slack-wizard-resume" | "slack-endpoint-reconnect" | "device-approve" | "device-expired"; device?: DevicePrivateInput; slackSetup?:SlackSetupPrivateInput; slackIdentity?:SlackIdentityPrivateInput; grok?:GrokRoutinePrivateInput; companyId?:string; companyMetadata?:{name?:string;key?:string}; personal?:{definitionId:string;userId:string;viaMySecrets?:boolean}; gateway?: { id: string; oldTokenId: string; otherToken: string; endpointPath: string; readName: string; pendingTokenId?: string }; baseURL: string; setupURL: string; providerURL: string; credential: string; cookies: Cookie[]; executablePath?: string; channel?: string }
|
|
/** Separate Node process: no Playwright Test worker, API-step listener or artifact
|
|
* recorder exists. This module never enables tracing, video or screenshots. */
|
|
async function main() {
|
|
let attached = false; try { test.info(); attached = true; } catch { /* expected outside Test */ }
|
|
if (attached) throw new Error("Private browser worker cannot run inside Playwright Test");
|
|
const input = JSON.parse(await readFile(process.argv[2]!, "utf8")) as Request;
|
|
if (!input.credential || input.credential.length > (input.mode === "grok-routine-configure" ? 16384 : 8192) || !["secure-key", "canary", "gateway-token-rotation", "company-secret-create", "personal-secret-create", "grok-routine-configure", "slack-identity-confirm", "slack-endpoint-configure", "slack-wizard-confirm", "slack-wizard-resume", "slack-endpoint-reconnect", "device-approve", "device-expired"].includes(input.mode)) throw new Error("Invalid private browser request");
|
|
const base = new URL(input.baseURL);
|
|
if (base.hostname !== "127.0.0.1" || base.protocol !== "http:" || new URL(input.setupURL).origin !== base.origin) throw new Error("Private browser target is outside the disposable loopback instance");
|
|
const browser = await chromium.launch({ headless: true, ...(input.executablePath ? { executablePath: input.executablePath } : {}), ...(input.channel ? { channel: input.channel } : {}) });
|
|
try {
|
|
const context = await browser.newContext({ baseURL: base.origin });
|
|
await context.addCookies(input.cookies); const page = await context.newPage(); page.setDefaultTimeout(30_000);
|
|
if (input.mode === "device-approve" || input.mode === "device-expired") {
|
|
if (!validDevicePrivateInput(input.mode, input.baseURL, input.setupURL, input.companyId, input.device, input.credential) || !input.device) throw new Error("Invalid finite device verification request");
|
|
const session = await context.request.get("/api/auth/get-session");
|
|
if (!session.ok() || (await session.json()).user?.id !== input.device.userId) throw new Error("Device UI requires the same signed-in fixture person");
|
|
const description = page.waitForResponse(response => {
|
|
const url = new URL(response.url());
|
|
return url.origin === base.origin && url.pathname === "/api/mcp/device" && url.searchParams.get("user_code") === input.credential && response.request().method() === "GET";
|
|
});
|
|
await page.goto(input.setupURL, { waitUntil: "domcontentloaded" });
|
|
const described = await description;
|
|
if (input.mode === "device-expired") {
|
|
const body = await described.json();
|
|
if (described.status() !== 404 || body.error !== "invalid_request") throw new Error("Expired device UI did not receive the actual issuer rejection");
|
|
await page.getByText(/expired|invalid/i).first().waitFor();
|
|
if (await page.getByRole("button", { name: "Connect organization", exact: true }).isVisible()) throw new Error("Expired device still offers approval");
|
|
return { processIsolation: true, testRunnerAttached: false, tracingEnabled: false, videoEnabled: false, apiStepRecorder: false, deviceSessionVerified: true, expiredPageVisible: true };
|
|
}
|
|
if (!described.ok()) throw new Error("Device consent description unavailable");
|
|
const request = await described.json();
|
|
if (request.clientName !== "Paperclip disposable CLI" || request.requestedCompanyId !== input.companyId || request.requiresSignIn !== false || request.requestedWrite !== false || request.requestedConfigure !== false || request.agentConnection === true
|
|
|| !request.companies?.some((company: { id: string; name: string }) => company.id === input.companyId && company.name === input.device!.companyName)) throw new Error("Device consent does not match the exact owned read-only request");
|
|
await page.getByRole("heading", { name: "Connect Paperclip disposable CLI to Paperclip", exact: true }).waitFor();
|
|
await page.getByText(input.credential.toUpperCase(), { exact: true }).waitFor();
|
|
await page.getByText(input.device.companyName, { exact: true }).waitFor();
|
|
await page.getByText("Read all of your Paperclip data", { exact: true }).waitFor();
|
|
if (await page.getByText("Write all of your Paperclip data", { exact: true }).isVisible()) throw new Error("Device read-only request unexpectedly offers write authority");
|
|
const approval = page.waitForResponse(response => new URL(response.url()).origin === base.origin && new URL(response.url()).pathname === "/api/mcp/device/consent" && response.request().method() === "POST");
|
|
await page.getByRole("button", { name: "Connect organization", exact: true }).click();
|
|
const approved = await approval, submitted = approved.request().postDataJSON();
|
|
if (!approved.ok() || (await approved.json()).status !== "approved" || submitted.userCode !== input.credential || submitted.companyId !== input.companyId || submitted.decision !== "approve" || submitted.allowWrites !== false || submitted.allowConfiguration !== false) throw new Error("Actual device UI did not approve exactly the selected read-only company/code");
|
|
await page.getByRole("heading", { name: "Access approved", exact: true }).waitFor();
|
|
return { processIsolation: true, testRunnerAttached: false, tracingEnabled: false, videoEnabled: false, apiStepRecorder: false, deviceSessionVerified: true, currentCodeVisible: true, companyConsentVisible: true, readScopeVisible: true, humanApproved: true };
|
|
}
|
|
if (input.mode === "canary") {
|
|
await page.setContent('<input type="password" aria-label="App key" autocomplete="off">');
|
|
const field = page.getByLabel("App key"); await field.fill(input.credential);
|
|
if (await field.inputValue() !== input.credential) throw new Error("Canary entry failed");
|
|
return { processIsolation: true, testRunnerAttached: false, credentialEntered: true, tracingEnabled: false, videoEnabled: false, apiStepRecorder: false };
|
|
}
|
|
if(input.mode === "slack-wizard-confirm" || input.mode === "slack-wizard-resume"){
|
|
const identity=input.slackIdentity;
|
|
if(!validSlackWizardIdentityInput(input.companyId,identity,input.setupURL)||!identity)throw new Error("Invalid finite normal Slack wizard identity operation");
|
|
const session=await context.request.get("/api/auth/get-session");if(!session.ok()||(await session.json()).user?.id!==identity.userId)throw new Error("Slack wizard requires the exact same signed-in person");
|
|
const endpointPath=`/api/chat-endpoints/${identity.endpointId}`;
|
|
const before=await context.request.get(endpointPath);if(!before.ok())throw new Error("Slack wizard endpoint missing");const endpoint=await before.json();if(endpoint.companyId!==input.companyId||endpoint.provider!=="slack"||endpoint.status!=="verifying"||endpoint.setup?.step!=="test")throw new Error("Normal Slack wizard is not waiting at actual verified identity/test boundary");
|
|
const principals=await context.request.get(`${endpointPath}/principals`);if(!principals.ok())throw new Error("Slack command discovery unavailable");
|
|
const rows=await principals.json() as Array<{principalId:string;externalDetail:string;lastConnectAt?:string;status:string;paperclipUserId?:string}>;const candidate=rows.filter(row=>row.principalId===identity.principalId&&row.externalDetail===identity.externalDetail&&row.lastConnectAt&&Date.parse(row.lastConnectAt)>=Date.parse(endpoint.setup.testStartedAt));if(candidate.length!==1||(input.mode==="slack-wizard-resume"?(candidate[0]!.status!=="linked"||candidate[0]!.paperclipUserId!==identity.userId):candidate[0]!.status==="linked"))throw new Error("Genuine current Slack connect receipt is required before wizard ownership confirmation");
|
|
await page.goto(input.setupURL,{waitUntil:"domcontentloaded"});
|
|
if(input.mode==="slack-wizard-confirm"){
|
|
const intentResponse=page.waitForResponse(response=>new URL(response.url()).pathname===`${endpointPath}/principals/${identity.principalId}/link-intent`&&response.request().method()==="POST"&&response.status()===201);
|
|
const confirmed=page.waitForResponse(response=>new URL(response.url()).pathname==="/api/chat-identity-links/confirm"&&response.request().method()==="POST"&&response.ok());
|
|
await page.getByRole("button",{name:`Link ${identity.externalLabel} to my Paperclip account`,exact:true}).click();
|
|
const intent=await(await intentResponse).json(),token=new URL(intent.confirmationUrl,base.origin).searchParams.get("token"),confirmation=await confirmed;
|
|
if(!token||confirmation.request().postDataJSON()?.token!==token||(await confirmation.json()).endpointId!==identity.endpointId)throw new Error("Actual Slack ownership button did not confirm its own generated scoped link");
|
|
}
|
|
await page.getByText("Linked to you",{exact:true}).waitFor();
|
|
const linkedResponse=await context.request.get(`${endpointPath}/principals`);if(!linkedResponse.ok()||!(await linkedResponse.json()).some((row:{principalId:string;status:string;paperclipUserId?:string})=>row.principalId===identity.principalId&&row.status==="linked"&&row.paperclipUserId===identity.userId))throw new Error("Wizard ownership did not persist as the exact same person");
|
|
await page.getByRole("button",{name:"Continue to message test",exact:true}).click();
|
|
const activation=page.waitForResponse(response=>new URL(response.url()).pathname===`${endpointPath}/finish`&&response.request().method()==="POST"&&response.ok());
|
|
await page.getByRole("button",{name:"Done",exact:true}).click();const activated=await(await activation).json();
|
|
const afterResponse=await context.request.get(endpointPath);if(!afterResponse.ok())throw new Error("Activated Slack endpoint unavailable");const after=await afterResponse.json();
|
|
if(activated.id!==identity.endpointId||activated.status!=="active"||after.status!=="active"||after.companyId!==input.companyId||after.setup?.step!=="complete")throw new Error("Actual wizard Done did not independently save activation");
|
|
return {processIsolation:true,testRunnerAttached:false,tracingEnabled:false,videoEnabled:false,apiStepRecorder:false,endpointId:identity.endpointId,principalId:identity.principalId,paperclipUserId:identity.userId,identityConfirmed:true,identityUiVisible:true,humanSubmittedSecurely:true,activationUiVisible:true,endpointActive:true,identityWasAlreadyLinked:input.mode==="slack-wizard-resume"};
|
|
}
|
|
if(input.mode === "slack-endpoint-configure" || input.mode === "slack-endpoint-reconnect"){
|
|
const slack=input.slackSetup;
|
|
if(!validSlackSetupPrivate(input.companyId,slack,input.setupURL,input.mode==="slack-endpoint-reconnect")||!slack||!input.companyId)throw new Error("Invalid finite existing Slack credential form");
|
|
const endpointPath=`/api/chat-endpoints/${slack.endpointId}`,beforeResponse=await context.request.get(endpointPath);
|
|
if(!beforeResponse.ok())throw new Error("Owned Slack draft unavailable");
|
|
const before=await beforeResponse.json();
|
|
if(before.companyId!==input.companyId||before.provider!=="slack"||before.assignedAgentId!==slack.agentId||before.setup?.slackSetupMethod!=="existing"||(input.mode==="slack-endpoint-reconnect"?before.providerAccountId!==slack.workspaceId:!!before.providerAccountId))throw new Error("Slack credential form must target the exact fresh existing-app draft");
|
|
await page.goto(input.setupURL,{waitUntil:"domcontentloaded"});
|
|
const bot=page.getByLabel("Bot User OAuth Token",{exact:true}),secret=page.getByLabel("Signing Secret",{exact:true});
|
|
await bot.waitFor();if(await bot.getAttribute("type")!=="password"||await secret.getAttribute("type")!=="password")throw new Error("Slack credential fields are not protected");
|
|
await bot.fill(input.credential);await secret.fill(slack.signingSecret);
|
|
if(await page.getByText("Your Bot User OAuth Token must start with",{exact:false}).isVisible()){
|
|
return {processIsolation:true,testRunnerAttached:false,tracingEnabled:false,videoEnabled:false,apiStepRecorder:false,endpointId:slack.endpointId,slackCredentialUiVisible:true,humanSubmittedSecurely:false,credentialUiRejected:true};
|
|
}
|
|
|
|
const saved=page.waitForResponse(response=>new URL(response.url()).pathname===`${endpointPath}/setup`&&response.request().method()==="POST"&&response.ok());
|
|
await page.getByRole("button",{name:input.mode==="slack-endpoint-reconnect"?"Reconnect Slack app":"Connect Slack app",exact:true}).click();const response=await saved,submitted=response.request().postDataJSON();
|
|
if(submitted.action!==(input.mode==="slack-endpoint-reconnect"?"reconnect":"configure")||submitted.credentials?.botToken!==input.credential||submitted.credentials?.signingSecret!==slack.signingSecret)throw new Error("Slack UI submitted another private setup operation");
|
|
const result=await response.json(),afterResponse=await context.request.get(endpointPath);if(!afterResponse.ok())throw new Error("Saved Slack endpoint unavailable");const after=await afterResponse.json();
|
|
if(result.id!==slack.endpointId||after.id!==slack.endpointId||after.companyId!==input.companyId||after.assignedAgentId!==slack.agentId||after.providerAccountId!==slack.workspaceId||!after.setup?.webhookSecretConfigured)throw new Error("Slack UI credential save did not persist the intended real workspace/agent");
|
|
await bot.waitFor({state:"hidden"});const storageClean=await page.evaluate(values=>!values.some(value=>JSON.stringify([Object.entries(localStorage),Object.entries(sessionStorage),document.body.innerText,location.href]).includes(value)),[input.credential,slack.signingSecret]);
|
|
return {processIsolation:true,testRunnerAttached:false,tracingEnabled:false,videoEnabled:false,apiStepRecorder:false,endpointId:slack.endpointId,slackCredentialUiVisible:true,humanSubmittedSecurely:true,storageClean};
|
|
}
|
|
if (input.mode === "slack-identity-confirm") {
|
|
const identity = input.slackIdentity;
|
|
if (!validSlackIdentityInput(input.companyId, identity, input.setupURL) || !identity || !input.companyId) throw new Error("Invalid finite Slack confirmation operation");
|
|
const session = await context.request.get("/api/auth/get-session");
|
|
if (!session.ok() || (await session.json()).user?.id !== identity.userId) throw new Error("Slack confirmation must use the same actual signed-in fixture human");
|
|
const principalsPath = `/api/chat-endpoints/${identity.endpointId}/principals`;
|
|
async function principal() {
|
|
const response = await context.request.get(principalsPath);
|
|
if (!response.ok()) throw new Error("Slack principal cannot be independently read");
|
|
const rows = await response.json() as Array<{ principalId:string; status:string; paperclipUserId?:string; externalDetail?:string }>;
|
|
const matches = rows.filter(row => row.principalId === identity!.principalId && row.externalDetail === identity!.externalDetail);
|
|
if (matches.length !== 1) throw new Error("Slack principal identity is ambiguous");
|
|
return matches[0]!;
|
|
}
|
|
if ((await principal()).status === "linked") throw new Error("Slack identity was already linked before human confirmation");
|
|
await page.goto(input.setupURL, {waitUntil:"domcontentloaded"});
|
|
const row = page.getByRole("list", {name:"People",exact:true}).getByRole("listitem").filter({has:page.getByText(identity.externalLabel,{exact:true})});
|
|
if(await row.count()!==1) throw new Error("Exact unlinked Slack identity is not uniquely visible in Access");
|
|
const issued=page.waitForResponse(response=>new URL(response.url()).pathname===`${principalsPath}/${identity.principalId}/link-intent`&&response.request().method()==="POST"&&response.status()===201);
|
|
await row.getByRole("button", {name:"Create confirmation link",exact:true}).click();
|
|
const intent=await(await issued).json() as {confirmationUrl?:string};
|
|
const confirmation=new URL(intent.confirmationUrl??"",base.origin),token=confirmation.searchParams.get("token");
|
|
if(confirmation.origin!==base.origin||confirmation.pathname!=="/chat-identity/confirm"||!token||token.length<32||token.length>8192||confirmation.hash||confirmation.searchParams.size!==1) throw new Error("Visible identity form returned another confirmation surface");
|
|
const previewResponse = await context.request.get(`/api/chat-identity-links/preview?token=${encodeURIComponent(token)}`);
|
|
if (!previewResponse.ok() || !matchingSlackIdentityPreview(await previewResponse.json(), input.companyId, identity)) throw new Error("Slack link preview targets another company, endpoint or external human");
|
|
await page.goto(confirmation.href, {waitUntil:"domcontentloaded"});
|
|
await page.getByRole("heading", {name:"Link your external identity",exact:true}).waitFor();
|
|
await page.getByText(identity.externalDetail, {exact:false}).waitFor();
|
|
const confirmed = page.waitForResponse(response => new URL(response.url()).pathname === "/api/chat-identity-links/confirm" && response.request().method() === "POST" && response.ok());
|
|
await page.getByRole("button", {name:"Confirm identity",exact:true}).click();
|
|
const response = await confirmed;
|
|
if (response.request().postDataJSON()?.token !== token) throw new Error("Slack confirmation submitted a different one-use token");
|
|
const result = await response.json() as {ok?:boolean;endpointId?:string};
|
|
if (result.ok !== true || result.endpointId !== identity.endpointId) throw new Error("Slack confirmation returned another endpoint");
|
|
await page.getByRole("heading", {name:"Identity linked",exact:true}).waitFor();
|
|
const after = await principal();
|
|
const afterSession = await context.request.get("/api/auth/get-session");
|
|
if (after.status !== "linked" || after.paperclipUserId !== identity.userId || !afterSession.ok() || (await afterSession.json()).user?.id !== identity.userId) throw new Error("Confirmed Slack authority does not bind the same current signed-in person");
|
|
return {processIsolation:true,testRunnerAttached:false,tracingEnabled:false,videoEnabled:false,apiStepRecorder:false,identityConfirmed:true,identityUiVisible:true,principalId:identity.principalId,paperclipUserId:identity.userId,humanSubmittedSecurely:true};
|
|
}
|
|
if (input.mode === "grok-routine-configure") {
|
|
const grok = input.grok;
|
|
if (!validGrokRoutinePrivateInput(input.companyId, grok, input.setupURL) || !grok) {
|
|
throw new Error("Invalid finite Grok routine operation");
|
|
}
|
|
const endpoint = `/api/companies/${input.companyId}/agents/${grok.agentId}/grok-binding`;
|
|
const beforeResponse = await context.request.get(endpoint);
|
|
if (!beforeResponse.ok()) throw new Error("Existing Grok binding is unavailable");
|
|
const before = await beforeResponse.json() as { enabled?: boolean; canConfigureConnection?: boolean; binding?: { id?: string; connected?: boolean; routine?: { revision?: number; configured?: boolean } } };
|
|
if (!before.enabled || !before.canConfigureConnection || before.binding?.id !== grok.bindingId ||
|
|
before.binding.connected !== true || (before.binding.routine?.revision ?? 0) !== grok.expectedRevision) {
|
|
throw new Error("Genuine connected Grok binding and exact revision are required");
|
|
}
|
|
await page.goto(input.setupURL, { waitUntil: "domcontentloaded" });
|
|
const section = page.getByRole("region", { name: "Grok Bot connection", exact: true });
|
|
await section.waitFor();
|
|
if (before.binding.routine?.configured) {
|
|
await section.getByRole("button", { name: "Replace routine credentials", exact: true }).click();
|
|
}
|
|
const urlField = section.getByLabel("Webhook URL", { exact: true });
|
|
const keyField = section.getByLabel("Webhook key", { exact: true });
|
|
if (await urlField.getAttribute("type") !== "password" || await keyField.getAttribute("type") !== "password") {
|
|
throw new Error("Grok credential fields are not protected");
|
|
}
|
|
await section.getByLabel("Bot name in Grok", { exact: true }).fill(grok.botName);
|
|
await urlField.fill(grok.webhookUrl);
|
|
await keyField.fill(input.credential);
|
|
const savedResponse = page.waitForResponse(response => new URL(response.url()).pathname === `${endpoint}/routine` &&
|
|
response.request().method() === "PUT" && response.ok());
|
|
await section.getByRole("button", { name: "Save routine", exact: true }).click();
|
|
const response = await savedResponse;
|
|
const submitted = response.request().postDataJSON() as { bindingId?: string; expectedRevision?: number; botName?: string; webhookUrl?: string; webhookKey?: string };
|
|
if (submitted.bindingId !== grok.bindingId || submitted.expectedRevision !== grok.expectedRevision ||
|
|
submitted.botName !== grok.botName || submitted.webhookUrl !== grok.webhookUrl || submitted.webhookKey !== input.credential) {
|
|
throw new Error("Grok form did not submit the intended scoped private configuration");
|
|
}
|
|
const saved = await response.json() as { bindingId?: string; configured?: boolean; verified?: boolean; revision?: number };
|
|
if (!matchesConfiguredGrokRoutine(saved, grok.bindingId, grok.expectedRevision)) {
|
|
throw new Error("Grok routine response does not match the configured unverified binding");
|
|
}
|
|
await section.getByRole("button", { name: "Verify connection", exact: true }).waitFor();
|
|
await keyField.waitFor({ state: "detached" });
|
|
const afterResponse = await context.request.get(endpoint);
|
|
if (!afterResponse.ok()) throw new Error("Saved Grok binding could not be independently read");
|
|
const after = await afterResponse.json() as { binding?: { id?: string; connected?: boolean; routine?: { configured?: boolean; revision?: number } } };
|
|
if (after.binding?.id !== grok.bindingId || after.binding.connected !== true ||
|
|
after.binding.routine?.configured !== true || after.binding.routine.revision !== saved.revision) {
|
|
throw new Error("Saved Grok configuration did not persist on the same connected binding");
|
|
}
|
|
const storageClean = await page.evaluate(values => !values.some(value =>
|
|
JSON.stringify([Object.entries(localStorage), Object.entries(sessionStorage), document.body.innerText, location.href]).includes(value)),
|
|
[input.credential, grok.webhookUrl]);
|
|
return { processIsolation: true, testRunnerAttached: false, tracingEnabled: false, videoEnabled: false, apiStepRecorder: false,
|
|
bindingId: grok.bindingId, routineConfigured: true, routineUiVisible: true, humanSubmittedSecurely: true, storageClean };
|
|
}
|
|
if(input.mode === "personal-secret-create"){
|
|
const personal=input.personal,setup=new URL(input.setupURL);
|
|
if (personal?.viaMySecrets !== undefined && typeof personal.viaMySecrets !== 'boolean') throw new Error('Invalid finite personal secret navigation');
|
|
if(!input.companyId||!/^[a-f0-9-]{36}$/i.test(input.companyId)||!personal||!/^[a-f0-9-]{36}$/i.test(personal.definitionId)||!personal.userId||personal.userId.length>160||!setup.pathname.endsWith('/company/settings/secrets')||(personal.viaMySecrets ? setup.search!=='' : setup.searchParams.get('definition')!==personal.definitionId))throw new Error('Invalid finite personal secret operation');
|
|
if (!personal.viaMySecrets) await page.goto(input.setupURL,{waitUntil:'domcontentloaded'});
|
|
const session=await context.request.get('/api/auth/get-session');if(!session.ok()||(await session.json()).user?.id!==personal.userId)throw new Error('Personal secret human identity mismatch');
|
|
const companyResponse = await context.request.get(`/api/companies/${input.companyId}`);
|
|
const company = companyResponse.ok() ? await companyResponse.json() as { id: string; issuePrefix: string } : null;
|
|
if (company?.id !== input.companyId || setup.pathname !== `/${company.issuePrefix}/company/settings/secrets`) throw new Error('Personal secret company navigation mismatch');
|
|
const own=await context.request.get(`/api/companies/${input.companyId}/me/user-secrets`);if(!own.ok())throw new Error('Personal definition not readable');
|
|
const entries=await own.json() as Array<{definition:{id:string;key:string;name:string};secret:unknown}>;const entry=entries.find(row=>row.definition.id===personal.definitionId);if(!entry||entry.definition.key!=='PERSONAL_PUBLISHING_KEY'||entry.secret)throw new Error('Personal definition already configured or mismatched');
|
|
if (personal.viaMySecrets) {
|
|
const settings = new URL(setup); settings.pathname = settings.pathname.replace(/\/secrets$/, '');
|
|
await page.goto(settings.href,{waitUntil:'domcontentloaded'});
|
|
await page.getByRole('link',{name:'Secrets',exact:true}).filter({visible:true}).click();
|
|
await page.getByRole('tab',{name:'My secrets',exact:true}).click();
|
|
await page.getByRole('listitem').filter({hasText:entry.definition.key}).getByRole('button',{name:'Set value',exact:true}).click();
|
|
} else await page.getByRole('button',{name:'Set my value',exact:true}).click();
|
|
const dialog=personalSecretEntryDialog(page);await dialog.getByRole('heading',{name:/^Set your value/}).waitFor();
|
|
if(!await dialog.getByText('Personal publishing credential',{exact:true}).isVisible())throw new Error('Wrong personal credential form');
|
|
await dialog.getByRole('textbox').fill(input.credential);
|
|
const created=page.waitForResponse(response=>new URL(response.url()).pathname===`/api/companies/${input.companyId}/me/user-secrets`&&response.request().method()==='POST'&&response.status()===201);
|
|
await dialog.getByRole('button',{name:'Save value',exact:true}).click();const secret=await(await created).json() as {id:string;ownerUserId:string;userSecretDefinitionId:string};
|
|
if(!/^[a-f0-9-]{36}$/i.test(secret.id)||secret.ownerUserId!==personal.userId||secret.userSecretDefinitionId!==personal.definitionId)throw new Error('Personal value owner/definition mismatch');await dialog.waitFor({state:'hidden'});
|
|
const storageClean=await page.evaluate(value=>!JSON.stringify([Object.entries(localStorage),Object.entries(sessionStorage),document.body.innerText,location.href]).includes(value),input.credential);
|
|
return {processIsolation:true,testRunnerAttached:false,tracingEnabled:false,videoEnabled:false,apiStepRecorder:false,secretId:secret.id,secretUiVisible:true,humanSubmittedSecurely:true,storageClean};
|
|
}
|
|
if(input.mode === "company-secret-create"){
|
|
if(!validCompanySecretMetadata(input.companyMetadata))throw new Error("Invalid requested company secret metadata");
|
|
const requestedName=input.companyMetadata?.name??"Deployment API key",requestedKey=input.companyMetadata?.key;
|
|
if(!input.companyId||!/^[a-f0-9-]{36}$/i.test(input.companyId)||!new URL(input.setupURL).pathname.endsWith("/company/settings/secrets"))throw new Error("Invalid finite company secret operation");
|
|
await page.goto(input.setupURL,{waitUntil:"domcontentloaded"});await page.getByRole("button",{name:"New secret",exact:true}).click();
|
|
const dialog=page.getByRole("dialog",{name:"Create secret",exact:true});await dialog.getByLabel("Name",{exact:true}).fill(requestedName);
|
|
if(requestedKey){await dialog.getByRole("button",{name:"Edit",exact:true}).click();await dialog.getByLabel("Key",{exact:true}).fill(requestedKey);}
|
|
await dialog.getByLabel("Provider",{exact:true}).selectOption("local_encrypted");
|
|
await dialog.getByLabel("Value",{exact:true}).fill(input.credential);
|
|
const created=page.waitForResponse(response=>new URL(response.url()).pathname===`/api/companies/${input.companyId}/secrets`&&response.request().method()==="POST"&&response.status()===201);
|
|
await dialog.getByRole("button",{name:"Create secret",exact:true}).click();const response=await created,posted=response.request().postDataJSON() as {name?:string;key?:string};
|
|
if(posted.name!==requestedName||requestedKey&&posted.key!==requestedKey)throw new Error("Outgoing company secret metadata differs from the actual request");
|
|
const submittedKey=requestedKey??posted.key??requestedName,secret=await response.json() as {id:string;companyId:string;name:string;key:string;scope:string;status:string};
|
|
if(secret.companyId!==input.companyId||secret.scope!=="company"||secret.status!=="active"||secret.name!==requestedName||secret.key!==canonicalCompanySecretKey(submittedKey))throw new Error("Saved company secret metadata differs from the actual request");
|
|
if(!/^[a-f0-9-]{36}$/i.test(secret.id))throw new Error("Invalid created secret identity");await dialog.waitFor({state:"hidden"});
|
|
await page.getByText(requestedName,{exact:true}).first().waitFor();
|
|
const storageClean=await page.evaluate(secret=>!JSON.stringify([Object.entries(localStorage),Object.entries(sessionStorage),document.body.innerText,location.href]).includes(secret),input.credential);
|
|
return {processIsolation:true,testRunnerAttached:false,tracingEnabled:false,videoEnabled:false,apiStepRecorder:false,secretId:secret.id,secretUiVisible:true,humanSubmittedSecurely:true,storageClean,requestedCompanyName:requestedName,requestedCompanyKey:submittedKey,savedCompanyName:secret.name,savedCompanyKey:secret.key};
|
|
}
|
|
if (input.mode === "gateway-token-rotation") {
|
|
const g = input.gateway;
|
|
if (!g || !/^[a-f0-9-]{36}$/i.test(g.id) || !/^[a-f0-9-]{36}$/i.test(g.oldTokenId) || (g.pendingTokenId !== undefined && !/^[a-f0-9-]{36}$/i.test(g.pendingTokenId)) || !g.otherToken || g.otherToken.length > 8192 || !g.readName || g.readName.length > 240) throw new Error("Invalid finite gateway rotation request");
|
|
const setup = new URL(input.setupURL), endpoint = new URL(g.endpointPath, base.origin);
|
|
if (!setup.pathname.endsWith(`/apps/gateways/${g.id}/tokens`) || endpoint.origin !== base.origin || !/^\/mcp\/gateways\/[^/]+$|^\/api\/tool-gateway\/gateways\/[^/]+\/mcp$/.test(endpoint.pathname)) throw new Error("Invalid gateway UI/protocol boundary");
|
|
async function discover(token: string) {
|
|
const response = await fetch(endpoint, { method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000), headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({jsonrpc:"2.0",id:1,method:"tools/list",params:{}}) });
|
|
const status = response.status; if (!response.ok) { await response.body?.cancel(); return { status, ok: false }; }
|
|
const body = await response.json() as {result?: {tools?: Array<{name: string}>}}; return {status, ok: body.result?.tools?.some(tool => tool.name === g!.readName) === true};
|
|
}
|
|
await page.goto(input.setupURL, {waitUntil:"domcontentloaded"});
|
|
let replacement: {id: string; token: string};
|
|
if (g.pendingTokenId) {
|
|
// The agent created the replacement; the person reveals its value once.
|
|
const waiting = page.getByRole("region", {name:"Tokens waiting to be revealed"});
|
|
const revealed = page.waitForResponse(response => new URL(response.url()).pathname === `/api/tool-gateway/gateway-tokens/${g.pendingTokenId}/reveal` && response.request().method() === "POST" && response.status() === 200);
|
|
await waiting.getByRole("button", {name:/^Reveal token /}).click();
|
|
replacement = await (await revealed).json() as {id: string; token: string};
|
|
if (replacement.id !== g.pendingTokenId) throw new Error("Revealed a different token than the agent created");
|
|
} else {
|
|
await page.getByRole("button", {name:"Issue token",exact:true}).click();
|
|
await page.getByRole("textbox", {name:/^Token name/}).fill("Replacement test client");
|
|
const issued = page.waitForResponse(response => new URL(response.url()).pathname === `/api/tool-gateway/gateways/${g.id}/tokens` && response.request().method() === "POST" && response.status() === 201);
|
|
await page.locator("form").getByRole("button", {name:"Issue token",exact:true}).click();
|
|
replacement = await (await issued).json() as {id: string; token: string};
|
|
}
|
|
if (!/^[a-f0-9-]{36}$/i.test(replacement.id) || typeof replacement.token !== "string" || !replacement.token || replacement.token.length > 8192) throw new Error("Invalid one-time token");
|
|
await page.getByText("New token — copy now", {exact:true}).waitFor();
|
|
if (!await page.locator("code").filter({hasText:replacement.token}).count()) throw new Error("One-time token was not visibly delivered");
|
|
const newWorkedBeforeRevocation = (await discover(replacement.token)).ok, oldWorkedUntilRevocation = (await discover(input.credential)).ok;
|
|
if (!newWorkedBeforeRevocation || !oldWorkedUntilRevocation) throw new Error("Replacement must work before retiring old credential");
|
|
await page.getByRole("button", {name:"Dismiss new token",exact:true}).click();
|
|
// Identify the old token by its saved prefix: an agent may give the replacement the same name.
|
|
const oldRow = page.getByRole("row").filter({has:page.getByText(`pcgw_${g.oldTokenId.slice(0, 8)}•••`, {exact:true})});
|
|
if (!await oldRow.isVisible()) await page.getByRole("button", {name:/Token history/}).click();
|
|
await oldRow.getByRole("button", {name:"Revoke",exact:true}).click();
|
|
const dialog = page.getByRole("dialog"); await dialog.getByLabel("Type the token name to confirm", {exact:true}).fill("Old test client");
|
|
const revoked = page.waitForResponse(response => new URL(response.url()).pathname === `/api/tool-gateway/gateway-tokens/${g.oldTokenId}/revoke` && response.request().method() === "POST" && response.ok());
|
|
await dialog.getByRole("button", {name:"Revoke token",exact:true}).click(); await revoked; await dialog.waitFor({state:"hidden"});
|
|
const old = await discover(input.credential), fresh = await discover(replacement.token), other = await discover(g.otherToken);
|
|
await writeFile(path.join(path.dirname(process.argv[2]!), "token.json"), JSON.stringify({id:replacement.id,token:replacement.token}), {mode:0o600});
|
|
return {processIsolation:true,testRunnerAttached:false,tracingEnabled:false,videoEnabled:false,apiStepRecorder:false,newTokenId:replacement.id,tokenUiVisible:true,newWorkedBeforeRevocation,oldWorkedUntilRevocation,revealedPending:!!g.pendingTokenId,oldRevoked:true,newWorksAfter:fresh.ok,oldStatusAfter:old.status,otherWorksAfter:other.ok};
|
|
}
|
|
await page.goto(input.setupURL, { waitUntil: "domcontentloaded" });
|
|
await page.getByRole("textbox", { name: "MCP server URL", exact: true }).fill(input.providerURL); await page.getByRole("button", { name: "Continue", exact: true }).click();
|
|
await page.getByRole("button", { name: "Check link", exact: true }).click();
|
|
const key = page.getByLabel("App key", { exact: true }); await key.waitFor({ state: "visible" });
|
|
const passwordInput = await key.getAttribute("type") === "password", autocompleteOff = await key.getAttribute("autocomplete") === "off";
|
|
await key.fill(input.credential);
|
|
const created = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/tools/apps/connect") && response.request().method() === "POST" && response.status() === 201);
|
|
await page.getByRole("button", { name: "Check link", exact: true }).click();
|
|
const connectionId = (await (await created).json() as { connectionId: string }).connectionId;
|
|
if (!/^[a-f0-9-]{36}$/i.test(connectionId)) throw new Error("Secure setup did not return a bounded connection identity");
|
|
await page.getByRole("heading", { name: /is ready\./i }).waitFor({ state: "visible" });
|
|
const storageClean = await page.evaluate(secret => !JSON.stringify([Object.entries(localStorage), Object.entries(sessionStorage), location.href]).includes(secret), input.credential);
|
|
await page.getByRole("button", { name: "View connection", exact: true }).click(); await key.waitFor({ state: "detached" });
|
|
return { processIsolation: true, testRunnerAttached: false, tracingEnabled: false, videoEnabled: false, apiStepRecorder: false, connectionId, challengeVisible: true, passwordInput, autocompleteOff, humanSubmittedSecurely: true, visibleReady: true, storageClean };
|
|
} finally { await browser.close(); }
|
|
}
|
|
main().then(receipt => process.stdout.write(JSON.stringify(receipt))).catch(() => { process.stderr.write("Private browser operation failed; diagnostics withheld"); process.exitCode = 1; });
|