fix: unblock clean-machine onboarding for api_key AI connections (nightly smoke) (#13372)

## 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
This commit is contained in:
Devin Foley authored and GitHub committed 2026-09-13 17:02:02 -07:00
1 parent f2c5e54dca
commit 13368c5183
4 files changed
+324 -9

No files matched your search

+96
View File
@@ -11,6 +11,16 @@ SMOKE_DETACH="${SMOKE_DETACH:-false}"
SMOKE_METADATA_FILE="${SMOKE_METADATA_FILE:-}"
PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}"
PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}"
# Serve api.anthropic.com from a mock inside the harness. Connecting a model
# is live-verified against provider endpoints that are deliberately hardcoded
# in the product (see validateAiApiKey), so a smoke that finishes onboarding
# needs the provider to say yes — and a release gate must not depend on a real
# paid credential or a provider's uptime. The product is not touched: the
# container's DNS for that one hostname points at the mock, and the mock's
# self-signed certificate is trusted via NODE_EXTRA_CA_CERTS. What the gate
# proves is that the artifact can finish onboarding when the provider accepts
# the credential — the provider's actual verdict is not this artifact's code.
SMOKE_PROVIDER_MOCK="${SMOKE_PROVIDER_MOCK:-true}"
PAPERCLIP_PUBLIC_URL="${PAPERCLIP_PUBLIC_URL:-http://localhost:${HOST_PORT}}"
SMOKE_AUTO_BOOTSTRAP="${SMOKE_AUTO_BOOTSTRAP:-true}"
# Seconds to wait for /api/health after the container starts. The container
@@ -27,6 +37,8 @@ SMOKE_ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-paperclip-smoke-password}"
# need one.
CONTAINER_NAME="${SMOKE_CONTAINER_NAME:-$IMAGE_NAME}"
CONTAINER_NAME="${CONTAINER_NAME//[^a-zA-Z0-9_.-]/-}"
PROVIDER_MOCK_CONTAINER_NAME="$CONTAINER_NAME-provider-mock"
PROVIDER_MOCK_DIR="$DATA_DIR-provider-mock"
# Where the container's logs are written before it is torn down. See
# `dump_container_logs`.
SMOKE_LOG_FILE="${SMOKE_LOG_FILE:-${TMPDIR:-/tmp}/${CONTAINER_NAME}.log}"
@@ -77,6 +89,8 @@ cleanup() {
if [[ "$PRESERVE_CONTAINER_ON_EXIT" != "true" ]]; then
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$PROVIDER_MOCK_CONTAINER_NAME" >/dev/null 2>&1 || true
rm -rf "$PROVIDER_MOCK_DIR" >/dev/null 2>&1 || true
fi
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
rm -rf "$TMP_DIR"
@@ -297,6 +311,87 @@ docker build \
-t "$IMAGE_NAME" \
"$REPO_ROOT"
# Extra `docker run` arguments for the app container when the provider mock is
# on: the DNS override, the mock's CA, and the mount that carries it.
PROVIDER_MOCK_RUN_ARGS=()
start_provider_mock() {
rm -rf "$PROVIDER_MOCK_DIR"
mkdir -p "$PROVIDER_MOCK_DIR"
chmod 755 "$PROVIDER_MOCK_DIR"
# A self-signed leaf is its own trust anchor: presented by the mock and
# listed in NODE_EXTRA_CA_CERTS, the one-certificate chain verifies and the
# SAN satisfies hostname verification for api.anthropic.com.
openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 7 \
-keyout "$PROVIDER_MOCK_DIR/key.pem" \
-out "$PROVIDER_MOCK_DIR/ca.pem" \
-subj "/CN=api.anthropic.com" \
-addext "subjectAltName=DNS:api.anthropic.com" >/dev/null 2>&1
# Only the certificate is public. The key stays 600 — the mock container
# runs as root and reads it through that — and is never mounted into the
# app container, which gets the lone certificate file below.
chmod 644 "$PROVIDER_MOCK_DIR/ca.pem"
chmod 600 "$PROVIDER_MOCK_DIR/key.pem"
# Only the one endpoint credential validation calls. Everything else 404s,
# so an unexpected provider call fails the flow loudly instead of being
# silently blessed by the mock.
cat >"$PROVIDER_MOCK_DIR/server.mjs" <<'MOCK_EOF'
import { createServer } from "node:https";
import { readFileSync } from "node:fs";
const server = createServer(
{
cert: readFileSync("/provider-mock/ca.pem"),
key: readFileSync("/provider-mock/key.pem"),
},
(req, res) => {
const path = new URL(req.url, "https://api.anthropic.com").pathname;
console.log(`[provider-mock] ${req.method} ${req.url}`);
if (req.method === "GET" && path === "/v1/models") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ data: [{ id: "claude-sonnet-5", type: "model" }], has_more: false }));
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: { type: "not_found_error", message: "provider mock: unexpected endpoint" } }));
},
);
server.listen(443, () => console.log("[provider-mock] listening on 443"));
MOCK_EOF
docker rm -f "$PROVIDER_MOCK_CONTAINER_NAME" >/dev/null 2>&1 || true
# The smoke image doubles as the mock's runtime: it already has node and is
# already built, so the mock costs no extra pull. Root, because binding 443
# inside the container needs it, and 443 is not negotiable — the product's
# provider endpoints are hardcoded https URLs.
docker run -d \
--name "$PROVIDER_MOCK_CONTAINER_NAME" \
--user 0 \
-v "$PROVIDER_MOCK_DIR:/provider-mock:ro" \
"$IMAGE_NAME" node /provider-mock/server.mjs >/dev/null
local mock_ip
mock_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PROVIDER_MOCK_CONTAINER_NAME")"
if [[ -z "$mock_ip" ]]; then
echo "Smoke bootstrap failed: provider mock container has no IP address" >&2
docker logs "$PROVIDER_MOCK_CONTAINER_NAME" >&2 || true
return 1
fi
PROVIDER_MOCK_RUN_ARGS=(
--add-host "api.anthropic.com:$mock_ip"
-v "$PROVIDER_MOCK_DIR/ca.pem:/provider-mock/ca.pem:ro"
-e NODE_EXTRA_CA_CERTS=/provider-mock/ca.pem
)
echo " Provider mock: api.anthropic.com -> $mock_ip (container $PROVIDER_MOCK_CONTAINER_NAME)"
}
if [[ "$SMOKE_PROVIDER_MOCK" == "true" ]]; then
echo "==> Starting provider mock"
start_provider_mock
fi
echo "==> Running onboard smoke container"
echo " UI should be reachable at: http://localhost:$HOST_PORT"
echo " Public URL: $PAPERCLIP_PUBLIC_URL"
@@ -325,6 +420,7 @@ docker run -d \
-e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \
-e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \
-v "$DATA_DIR:/paperclip" \
${PROVIDER_MOCK_RUN_ARGS[@]+"${PROVIDER_MOCK_RUN_ARGS[@]}"} \
"$IMAGE_NAME" >/dev/null
if [[ "$SMOKE_DETACH" != "true" ]]; then
@@ -100,6 +100,56 @@ const externalAdapter: ServerAdapterModule = {
testEnvironment: testEnvironmentSpy,
};
// Only the runtime preparation is replaced: it needs a database and a stored
// grant, and these tests exercise the route's verdict, not credential
// resolution. Everything else in the module stays real. The same goes for
// validateAiApiKey — it calls the provider's real endpoint, and these tests
// direct its verdict instead of the network.
const mockPrepareManagedAiRuntime = vi.hoisted(() => vi.fn());
vi.mock("../services/ai-connection-runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../services/ai-connection-runtime.js")>()),
prepareManagedAiRuntime: mockPrepareManagedAiRuntime,
}));
const mockValidateAiApiKey = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../routes/ai-connections.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../routes/ai-connections.js")>()),
validateAiApiKey: mockValidateAiApiKey,
}));
function mockManagedRuntime(method: "api_key" | "subscription") {
mockPrepareManagedAiRuntime.mockImplementation(
async (_db: unknown, input: { config: Record<string, unknown> }) => ({
config: {
...input.config,
// The real preparation injects the credential under the provider's
// env key; the adoption re-verification reads it from there.
env: { ...(method === "api_key" ? { ANTHROPIC_API_KEY: "sk-ant-test-key" } : {}) },
managedAiConnection: {
connectionId: "conn-1",
grantId: "grant-1",
provider: "anthropic",
method,
mode: "responsible_user",
responsibleUserId: "local-board",
identity: "grant-1:local-board:0000000000000000",
},
},
attribution: {
connectionId: "conn-1",
grantId: "grant-1",
provider: "anthropic",
method,
mode: "responsible_user",
responsibleUserId: "local-board",
},
accountName: "My Claude Account",
accountOwnerUserId: "local-board",
identity: "grant-1:local-board:0000000000000000",
cleanup: vi.fn(async () => {}),
}),
);
}
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
@@ -219,6 +269,95 @@ describe("agent test-environment route", () => {
}
});
// The managed-adoption verdict, all three ways. An api_key account is
// re-verified against the provider's endpoint at adoption — the same check
// its save performed, catching a key revoked since — but never through the
// CLI-lane hello probe, which a clean machine without a provider CLI can
// never pass (the regression that walled off onboarding's API-key path in
// the nightly release smoke). A stored subscription login still needs the
// hello probe: only a real turn proves the runtime lane can consume it.
it("adopts an api_key connection on the engine's verdict plus a live key check, without a CLI hello probe", async () => {
mockManagedRuntime("api_key");
// A sentinel in claude_local's slot: the forced CLI-lane fallback would
// land here, so the fix is proven by this never being consulted — not by
// whatever verdict the host machine's real adapter would return.
const { registerServerAdapter, getServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
const previous = getServerAdapter("claude_local");
unregisterServerAdapter("claude_local");
const cliProbeSpy = vi.fn(async () => ({
adapterType: "claude_local",
status: "fail" as const,
checks: [{ code: "adapter_command_missing", level: "error" as const, message: 'Command not found in PATH: "claude"' }],
testedAt: new Date(0).toISOString(),
}));
registerServerAdapter({ ...externalAdapter, type: "claude_local", testEnvironment: cliProbeSpy });
try {
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/adapters/external_test/test-environment")
.send({
adapterConfig: { cwd: "/" },
aiConnection: { provider: "anthropic", method: "api_key", mode: "responsible_user" },
});
expect(res.status).toBe(200);
expect(res.body.status).toBe("pass");
expect(JSON.stringify(res.body)).not.toContain("ai_connection_validation_incomplete");
expect(res.body.checks.map((check: { code: string }) => check.code)).toContain("ai_connection_api_key_reverified");
expect(mockValidateAiApiKey).toHaveBeenCalledWith("anthropic", "sk-ant-test-key");
expect(testEnvironmentSpy).toHaveBeenCalledTimes(1);
expect(cliProbeSpy).not.toHaveBeenCalled();
} finally {
unregisterServerAdapter("claude_local");
if (previous) registerServerAdapter(previous);
}
});
it("fails adoption of an api_key connection the provider no longer accepts", async () => {
mockManagedRuntime("api_key");
mockValidateAiApiKey.mockRejectedValueOnce(Object.assign(new Error("The provider rejected this API key."), { status: 422 }));
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/adapters/external_test/test-environment")
.send({
adapterConfig: { cwd: "/" },
aiConnection: { provider: "anthropic", method: "api_key", mode: "responsible_user" },
});
expect(res.status).toBe(200);
expect(res.body.status).toBe("fail");
expect(res.body.checks.map((check: { code: string }) => check.code)).toContain("ai_connection_api_key_rejected");
});
it("still fails subscription adoption when no hello probe can run", async () => {
mockManagedRuntime("subscription");
const { registerServerAdapter, getServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
const previous = getServerAdapter("claude_local");
unregisterServerAdapter("claude_local");
const cliProbeSpy = vi.fn(async () => ({
adapterType: "claude_local",
status: "fail" as const,
checks: [{ code: "adapter_command_missing", level: "error" as const, message: 'Command not found in PATH: "claude"' }],
testedAt: new Date(0).toISOString(),
}));
registerServerAdapter({ ...externalAdapter, type: "claude_local", testEnvironment: cliProbeSpy });
try {
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/adapters/external_test/test-environment")
.send({
adapterConfig: { cwd: "/" },
aiConnection: { provider: "anthropic", method: "subscription", mode: "responsible_user" },
});
expect(res.status).toBe(200);
expect(res.body.status).toBe("fail");
expect(res.body.checks.map((check: { code: string }) => check.code)).toContain("ai_connection_validation_incomplete");
expect(cliProbeSpy).toHaveBeenCalledTimes(1);
expect(cliProbeSpy.mock.calls[0]?.[0]?.config.engine).toBe("cli");
} finally {
unregisterServerAdapter("claude_local");
if (previous) registerServerAdapter(previous);
}
});
it("passes one-shot provider credentials only to the probe, never persistence normalization", async () => {
const app = await createApp();
const res = await request(app)
+31 -2
View File
@@ -1,9 +1,9 @@
import { listOpenRouterModels } from "../services/openrouter-models.js";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings } from "../services/ai-connection-runtime.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE, aiConnectionBindingSchema, type AiConnectionBinding } from "@paperclipai/shared";
import { ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, aiConnectionBindingSchema, type AiConnectionBinding } from "@paperclipai/shared";
import { toolConnections } from "@paperclipai/db";
import { aiConnectionService } from "../services/ai-connections.js";
import { assertAiConnectionCreateAccess, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "./ai-connections.js";
import { assertAiConnectionCreateAccess, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest, validateAiApiKey } from "./ai-connections.js";
import { isAiConnectionCompatible } from "@paperclipai/shared";
import { applyConnectorSkills, resolveConnectorAssignments, annotateConnectorSkills, isConnectorSkill } from "../services/connector-runtime.js";
import { getExecutionBlocker } from "../services/execution-blocker.js";
@@ -3178,6 +3178,35 @@ export function agentRoutes(
await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget);
const result = await requireServerAdapter(adapterType).testEnvironment(context);
if (result.status === "fail") return result;
// The resolved method, not binding.method — on a responsible_user binding
// that field is wire-compat only and the default connection decides.
const resolvedMethod = (context.config as { managedAiConnection?: { method?: string } }).managedAiConnection?.method;
// An api_key account does not take the CLI hello probe below: a key
// travels as an env var any engine understands, and the engine's own test
// above judged whether this runtime can execute with it — the ACP lane
// deliberately runs no hello probe when a key is configured. Demanding one
// anyway forced the CLI lane, whose probe needs a provider CLI on PATH,
// and a clean install has none: that walled off onboarding's API-key path
// on exactly the machines the release smoke exists to guard. The account
// is still verified live here — the same provider-endpoint check the save
// performed — so a key revoked since its save fails adoption rather than
// producing an agent that cannot authenticate at runtime. The hello-probe
// requirement stays for subscriptions: a stored login is a file layout
// only a provider CLI reads, so proving the runtime lane can consume it
// takes a real hello turn.
if (resolvedMethod === "api_key") {
const envKey = AI_CONNECTION_CAPABILITIES[binding.provider].methods.api_key?.envKey;
const key = envKey ? parseObject(context.config.env)[envKey] : undefined;
try {
if (typeof key !== "string" || !key) throw unprocessable("The selected account's API key was not available to verify.");
await validateAiApiKey(binding.provider, key);
result.checks.push({ code: "ai_connection_api_key_reverified", level: "info", message: "The provider verified this API key for adoption." });
} catch (error) {
result.status = "fail";
result.checks.push({ code: "ai_connection_api_key_rejected", level: "error", message: error instanceof HttpError ? error.message : "Could not verify the account. Try again." });
}
return result;
}
if (!result.checks.some(check => check.code.includes("hello_probe"))) {
const providerAdapter = { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local" }[binding.provider];
const probe = await requireServerAdapter(providerAdapter).testEnvironment({ ...context, adapterType: providerAdapter, config: { ...context.config, engine: "cli" } });
@@ -9,6 +9,29 @@ const ADMIN_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
@@ -76,6 +99,13 @@ 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;
@@ -112,23 +142,44 @@ test.describe("Docker authenticated onboarding smoke", () => {
// 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 and turns the button
// into "Connect". In the smoke container the resolved login environment is
// the local host, not a sandbox, so there is no sign-in to run and Connect
// goes straight to the hire, with the missing agent CLI reported by the
// probe as a warning rather than an error — a genuine failure here means
// the published artifact cannot hire on a clean machine. Allow generous
// time for the probe + hire + auto-approval.
// 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,