mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 21:03:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The release pipeline gates each nightly on a Docker onboarding smoke. The smoke proves a clean machine can finish onboarding and hire the first agent. > - #13247, #13248, #13344, and #13351 changed the Connect step. Connect now creates an AI connection that the server verifies live with the provider. > - The managed adoption check also demanded a CLI hello probe. A clean machine has no provider CLI and cannot complete a subscription login. Onboarding dead-ends and the nightly gate fails. > - This pull request lets a live-verified API key adopt on the engine's own verdict, and re-verifies the key with the provider at adoption time. > - It also drives the release smoke through the API-key path against a provider mock that lives inside the test harness. > - The benefit is a green, deterministic release gate with no paid credential in CI, and a working first run for API-key users on clean installs. ## Linked Issues or Issue Description No public issue exists. The failure surfaced in the nightly release gate. Related PRs (no duplicates found): #13247, #13248, #13344, #13351 (the Connect changes), and #12423, #12135, #12151 (earlier release-smoke updates). **What happened?** The nightly Release cut failed its gate: [run 34749840498](https://github.com/paperclipai/paperclip/actions/runs/34749840498), job `smoke_nightly / smoke`, on published canary `2026.913.0-canary.2`. The wizard never left the "Connect a model" step. The subscription path waits for a human to run `claude auth login` on the server. The API-key path saves and live-validates the key, but the environment test then fails with `Command not found in PATH: "claude"` and `ai_connection_validation_incomplete`, and the wizard blocks the hire. **Expected behavior** A clean machine with a provider-accepted API key completes onboarding and hires the lead agent. The release smoke passes without a real paid credential in CI. **Steps to reproduce** 1. Run `scripts/docker-onboard-smoke.sh` with `PAPERCLIPAI_VERSION=2026.913.0-canary.2`. 2. Sign in, complete onboarding to "Connect a model", select "Use API key instead", pick Claude, enter a valid API key, and press Connect. 3. The environment test fails on the missing `claude` CLI and blocks the hire. **Paperclip version or commit** `2026.913.0-canary.2` (nightly candidate `c9e3bb7ca`). ## What Changed - `server/src/routes/agents.ts`: `testManagedEnvironment` no longer forces the CLI-lane hello probe for a resolved `api_key` binding. It re-verifies the key against the provider's live endpoint instead (the same `validateAiApiKey` check the save performed, which needs no CLI). A key the provider rejects fails adoption with `ai_connection_api_key_rejected`. Subscription adoption keeps the strict hello-probe requirement. - `scripts/docker-onboard-smoke.sh`: the harness now serves `api.anthropic.com` itself. A sibling container (the already-built smoke image) runs a small HTTPS mock. The app container gets `--add-host` for that one hostname and trusts the mock's certificate through `NODE_EXTRA_CA_CERTS`. The private key stays mode 600 in the mock container; the app container mounts only the certificate. The mock serves only `GET /v1/models` and returns 404 for every other path. `SMOKE_PROVIDER_MOCK=false` disables it. - `tests/release-smoke/docker-auth-onboarding.spec.ts`: the spec drives the API-key path — switch the credential mode before the source tile (the link hides when the row collapses), enter the key, and Connect. Loopback targets use a placeholder key that the mock accepts. Any other target must set `PAPERCLIP_RELEASE_SMOKE_ANTHROPIC_API_KEY`, and the test fails on arrival without it. - `server/src/__tests__/agent-test-environment-routes.test.ts`: three new route tests cover accepted keys (no CLI probe consulted), provider-rejected keys, and subscriptions that cannot complete a hello probe. ## Verification - `npx vitest run src/__tests__/agent-test-environment-routes.test.ts` — 26/26 pass. - `npx vitest run src/__tests__/ai-connections.test.ts src/__tests__/ai-legacy-compatibility.test.ts` — 42/42 pass. - `tsc --noEmit` reports no errors in the touched files. - Full local harness + suite run against the exact failing canary: the app container reaches the mock (request visible in the mock log), the placeholder key validates, and the connection saves as the default. The flow then stops at the forced CLI hello probe — the exact server check this PR removes, still present in the published canary. The next canary that includes this fix is the end-to-end proof. - Hardening check: from inside the app container, the mock answers with status 200 and `key.pem` is not visible. ## Risks - Behavior shift: `api_key` adoption no longer requires a CLI hello probe. It re-verifies the key with the provider at adoption instead. Subscription adoption is unchanged. - The mock returns 404 for unexpected provider calls, so a future onboarding change that calls a new endpoint fails the smoke loudly instead of passing silently. - Release-smoke runs against non-loopback targets now require an explicit key and fail fast without one. - No database migration. No dependency change. No provider routing change. ## Model Used Claude Fable 5 (`claude-fable-5`) through the Claude Code CLI, with extended thinking and tool use (shell, file edits, Playwright runs, GitHub CLI). No other models were used. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
271 lines
12 KiB
TypeScript
271 lines
12 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
const ADMIN_EMAIL =
|
|
process.env.PAPERCLIP_RELEASE_SMOKE_EMAIL ??
|
|
process.env.SMOKE_ADMIN_EMAIL ??
|
|
"smoke-admin@paperclip.local";
|
|
const ADMIN_PASSWORD =
|
|
process.env.PAPERCLIP_RELEASE_SMOKE_PASSWORD ??
|
|
process.env.SMOKE_ADMIN_PASSWORD ??
|
|
"paperclip-smoke-password";
|
|
|
|
// A hire needs a live-verified credential since #13344 — the subscription
|
|
// path now dead-ends on a `claude auth login` no CI machine can finish — so
|
|
// the wizard is driven through "Use API key instead". The server verifies the
|
|
// key against api.anthropic.com, which the docker-onboard-smoke harness
|
|
// serves from its own mock inside the container's network, so the placeholder
|
|
// below passes without any real credential in CI.
|
|
//
|
|
// The placeholder is offered only to a loopback target — where the mocked
|
|
// harness lives. Any other target reaches the real provider, which would
|
|
// reject the placeholder late inside the wizard, so those runs must set
|
|
// PAPERCLIP_RELEASE_SMOKE_ANTHROPIC_API_KEY and fail up front without it.
|
|
// A real key entered here also lands in Playwright's failure traces and DOM
|
|
// snapshots (the field is masked on screen, not in the DOM) — those artifacts
|
|
// stay on the machine running the suite, and CI never uses a real key.
|
|
const BASE_URL =
|
|
process.env.PAPERCLIP_RELEASE_SMOKE_BASE_URL ?? "http://127.0.0.1:3232";
|
|
const TARGET_IS_LOOPBACK = /^https?:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(
|
|
BASE_URL
|
|
);
|
|
const ANTHROPIC_API_KEY =
|
|
process.env.PAPERCLIP_RELEASE_SMOKE_ANTHROPIC_API_KEY ??
|
|
(TARGET_IS_LOOPBACK ? "sk-ant-release-smoke-placeholder" : "");
|
|
|
|
const COMPANY_NAME = `Release-Smoke-${Date.now()}`;
|
|
const AGENT_NAME = "Release Smoke Lead";
|
|
// The arc asks for a name, not a role, so every onboarding hire is filed under
|
|
// the neutral role (DEFAULT_AGENT_ROLE in ui/src/lib/onboarding-agent-role.ts).
|
|
const AGENT_ROLE = "general";
|
|
// Seeded by the wizard's launch step (DEFAULT_TASK_TITLE in
|
|
// ui/src/components/OnboardingWizard.tsx).
|
|
const FIRST_TASK_TITLE = "Paperclip onboarding";
|
|
|
|
async function signIn(page: Page) {
|
|
await page.goto("/");
|
|
await expect(page).toHaveURL(/\/auth/);
|
|
|
|
await page.locator('input[type="email"]').fill(ADMIN_EMAIL);
|
|
await page.locator('input[type="password"]').fill(ADMIN_PASSWORD);
|
|
await page.getByRole("button", { name: "Sign In" }).click();
|
|
|
|
await expect(page).not.toHaveURL(/\/auth/, { timeout: 20_000 });
|
|
}
|
|
|
|
async function getJson<T>(page: Page, url: string): Promise<T> {
|
|
const response = await page.request.get(url);
|
|
expect(response.ok()).toBe(true);
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
// ONBOARDING_STORAGE_KEY in ui/src/components/OnboardingWizard.tsx.
|
|
const ONBOARDING_DRAFT_STORAGE_KEY = "paperclip-onboarding-state";
|
|
|
|
/**
|
|
* Open the wizard on its first step and hand back the organization-name field.
|
|
*
|
|
* `/onboarding` resolves to `{ initialStep: 1 }` on a self-hosted instance
|
|
* (`resolveRouteOnboardingOptions`) and the route keeps the wizard open, so
|
|
* this lands on "name your organization" whether or not the instance already
|
|
* holds a company. Navigating explicitly is what keeps the spec re-runnable:
|
|
* the release-smoke config retries once in CI, and by the second attempt the
|
|
* instance is no longer company-less, so sign-in lands on a dashboard instead.
|
|
*
|
|
* The saved draft is dropped first. Sign-in on an instance that already holds
|
|
* an agentless company redirects into *that* company's onboarding, which
|
|
* persists its id into the draft; the restored draft then makes step 1 skip
|
|
* creating a company and hire into the old one instead. That is an artifact of
|
|
* re-running against a re-used instance, not behaviour this spec is asserting,
|
|
* and a fresh release-smoke container never has it.
|
|
*
|
|
* The field is located by role. Step 1 has no id and its `<label>` is not
|
|
* associated with the input, so the alternative is its placeholder copy — the
|
|
* exact coupling that let this spec drift. The wizard's first screen has
|
|
* exactly one text box, and a second one appearing there would fail Playwright's
|
|
* strict mode loudly rather than silently matching the wrong control.
|
|
*/
|
|
async function openOnboarding(page: Page) {
|
|
await page.evaluate((key) => {
|
|
window.localStorage.removeItem(key);
|
|
}, ONBOARDING_DRAFT_STORAGE_KEY);
|
|
await page.goto("/onboarding");
|
|
|
|
const orgNameField = page.getByRole("textbox");
|
|
await expect(orgNameField).toBeVisible({ timeout: 20_000 });
|
|
return orgNameField;
|
|
}
|
|
|
|
test.describe("Docker authenticated onboarding smoke", () => {
|
|
test("logs in, completes onboarding, and hires the lead agent", async ({
|
|
page,
|
|
}) => {
|
|
// Only bites off-loopback: fail on arrival rather than submitting the
|
|
// placeholder to the real provider and timing out deep in the wizard.
|
|
expect(
|
|
ANTHROPIC_API_KEY,
|
|
"This target reaches the real provider — set PAPERCLIP_RELEASE_SMOKE_ANTHROPIC_API_KEY to a key it accepts"
|
|
).toBeTruthy();
|
|
|
|
await signIn(page);
|
|
|
|
const baseUrl = new URL(page.url()).origin;
|
|
|
|
// A board with no company routes sign-in straight into onboarding rather
|
|
// than a dashboard — the first-run experience this suite exists to guard.
|
|
// Asserted only when the instance really is company-less, because a retry
|
|
// (or a re-used smoke container) runs against one that is not.
|
|
const companiesBeforeOnboarding = await getJson<Array<{ id: string }>>(
|
|
page,
|
|
`${baseUrl}/api/companies`
|
|
);
|
|
if (companiesBeforeOnboarding.length === 0) {
|
|
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 20_000 });
|
|
}
|
|
|
|
// Step 1: name the organization. "Continue" creates the company itself and
|
|
// routes straight to the agent step — onboarding no longer asks for the
|
|
// mission (it is collected later, in the app), so step 2 is skipped.
|
|
const orgNameField = await openOnboarding(page);
|
|
await orgNameField.fill(COMPANY_NAME);
|
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
|
|
|
// Step 3: name the team lead. The name is the step's only question and it
|
|
// gates the CTA; the role picker is gone, so the hire is filed as `general`.
|
|
const agentNameField = page.locator("#onboarding-agent-name");
|
|
await expect(agentNameField).toBeVisible({ timeout: 20_000 });
|
|
await agentNameField.fill(AGENT_NAME);
|
|
|
|
const nextButton = page.getByRole("button", { name: "Next", exact: true });
|
|
await expect(nextButton).toBeEnabled({ timeout: 10_000 });
|
|
await nextButton.click();
|
|
|
|
// Step 4: answer the model-source question, then connect (hire) the lead.
|
|
// The step now opens as a row of source tiles and the footer button has
|
|
// nothing to do until one is picked (#12796/#12801 rebuilt the step around
|
|
// that question); picking Claude collapses the row.
|
|
//
|
|
// Since #13344, a hire requires a verified credential: the subscription
|
|
// path opens an isolated `claude auth login` attempt that only a human at
|
|
// a terminal on the server can finish, and Connect refuses to proceed
|
|
// until it has. The clean-machine path this suite guards is therefore the
|
|
// API key: switch modes, pick Claude, paste a key, Connect — the server
|
|
// verifies it against the provider endpoint (the harness's mock, here)
|
|
// and then hires. A genuine failure here means the published artifact
|
|
// cannot hire on a clean machine even when the provider accepts the
|
|
// credential. Allow generous time for the validation + hire +
|
|
// auto-approval.
|
|
//
|
|
// The mode switch comes before the tile: picking a tile starts the step's
|
|
// collapse sequence and the "Use API key instead" link only offers itself
|
|
// while the row is still a question (`connectLinkVisible` in
|
|
// OnboardingWizard.tsx).
|
|
await expect(
|
|
page.getByRole("heading", { name: "Connect a model" })
|
|
).toBeVisible({ timeout: 20_000 });
|
|
|
|
await page
|
|
.getByRole("button", { name: "Use API key instead", exact: true })
|
|
.click();
|
|
|
|
// "Claude API" once the mode has swapped the tile's tag — matched on the
|
|
// stable half.
|
|
const claudeSourceTile = page
|
|
.getByRole("radiogroup", { name: "Model source" })
|
|
.getByRole("radio", { name: /Claude/ });
|
|
await expect(claudeSourceTile).toBeVisible({ timeout: 10_000 });
|
|
await claudeSourceTile.click();
|
|
|
|
// OnboardingCardField carries the accessible name via aria-label.
|
|
const apiKeyField = page.getByLabel("API key");
|
|
await expect(apiKeyField).toBeVisible({ timeout: 10_000 });
|
|
await apiKeyField.fill(ANTHROPIC_API_KEY);
|
|
|
|
const connectButton = page.getByRole("button", {
|
|
name: "Connect",
|
|
exact: true,
|
|
});
|
|
await expect(connectButton).toBeVisible({ timeout: 10_000 });
|
|
await expect(connectButton).toBeEnabled({ timeout: 30_000 });
|
|
await connectButton.click();
|
|
|
|
// Step 5: review, then launch. "Get started" provisions the onboarding
|
|
// project and first task and, only on success, drops the user into the
|
|
// seeded first task's thread (not the dashboard).
|
|
const getStartedButton = page.getByRole("button", {
|
|
name: "Get started",
|
|
exact: true,
|
|
});
|
|
await expect(getStartedButton).toBeVisible({ timeout: 60_000 });
|
|
await expect(getStartedButton).toBeEnabled({ timeout: 10_000 });
|
|
await getStartedButton.click();
|
|
await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 });
|
|
|
|
const companies = await getJson<Array<{ id: string; name: string }>>(
|
|
page,
|
|
`${baseUrl}/api/companies`
|
|
);
|
|
const company = companies.find((entry) => entry.name === COMPANY_NAME);
|
|
expect(company).toBeTruthy();
|
|
|
|
const agents = await getJson<
|
|
Array<{ id: string; name: string; role: string; adapterType: string }>
|
|
>(page, `${baseUrl}/api/companies/${company!.id}/agents`);
|
|
const leadAgent = agents.find((entry) => entry.name === AGENT_NAME);
|
|
expect(leadAgent).toBeTruthy();
|
|
expect(leadAgent!.role).toBe(AGENT_ROLE);
|
|
expect(leadAgent!.adapterType).not.toBe("process");
|
|
|
|
// Onboarding deliberately writes no goal: the mission is collected later in
|
|
// the app, so a fresh company must come out of the wizard with an empty
|
|
// goal list rather than an unchosen one.
|
|
const goals = await getJson<Array<{ id: string }>>(
|
|
page,
|
|
`${baseUrl}/api/companies/${company!.id}/goals`
|
|
);
|
|
expect(goals).toEqual([]);
|
|
|
|
const issues = await getJson<
|
|
Array<{
|
|
id: string;
|
|
identifier: string | null;
|
|
title: string;
|
|
assigneeAgentId: string | null;
|
|
}>
|
|
>(page, `${baseUrl}/api/companies/${company!.id}/issues`);
|
|
const seededIssue = issues.find((entry) => entry.title === FIRST_TASK_TITLE);
|
|
expect(seededIssue).toBeTruthy();
|
|
expect(seededIssue!.assigneeAgentId).toBe(leadAgent!.id);
|
|
|
|
// The launch must have landed on the seeded task itself, not merely on
|
|
// some issue route.
|
|
const seededRef = seededIssue!.identifier ?? seededIssue!.id;
|
|
expect(new URL(page.url()).pathname.endsWith(`/issues/${seededRef}`)).toBe(
|
|
true
|
|
);
|
|
|
|
// #13068 rebuilt the seeded first task as a chat with the lead: launch
|
|
// posts a deterministic, server-owned greeting plus an opening question
|
|
// card, and deliberately does not wake the assignee — "no run until the
|
|
// user answers". Assert the chat actually opened (the greeting and the
|
|
// card are seeded without an LLM, so their absence means the launch
|
|
// half-finished) …
|
|
await expect(
|
|
page.getByText("Welcome to Paperclip!").first()
|
|
).toBeVisible({ timeout: 20_000 });
|
|
await expect(page.getByText("What would you like to do?")).toBeVisible();
|
|
|
|
// … and that the no-run contract holds. This spec used to poll for an
|
|
// assignment-triggered heartbeat run here; a run appearing before the
|
|
// user's first answer is now the regression, not the success. Wake
|
|
// dispatch is asynchronous, so watch the endpoint over a bounded window
|
|
// rather than sampling it once — a launch-time wake that slips through
|
|
// lands well within this window.
|
|
const runsUrl = `${baseUrl}/api/companies/${company!.id}/heartbeat-runs?agentId=${leadAgent!.id}`;
|
|
const noRunDeadline = Date.now() + 15_000;
|
|
while (Date.now() < noRunDeadline) {
|
|
expect(await getJson<Array<{ id: string }>>(page, runsUrl)).toEqual([]);
|
|
await page.waitForTimeout(1_000);
|
|
}
|
|
});
|
|
});
|