Create agent records when onboarding names are submitted (#17005)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Core onboarding selects an agent and connects its provider account.
> - The current flow creates the agent record after authentication.
> - Setup needs a stable agent identity as soon as the user submits its
name.
> - This PR saves a paused setup record on name submission and finishes
the hire on that same record.
> - The existing sandbox authentication and approval paths continue to
apply.

## Linked Issues or Issue Description

**Subsystem affected**

Core agent lifecycle, shared validators, and onboarding UI.

**Current behavior**

Onboarding creates the database record at the final hire step. Earlier
authentication and saved setup have no agent record.

**Proposed behavior**

Create a paused agent with a setup hold when the user submits its name.
Do this before adapter selection or authentication. Update that same
record after adapter selection. Reuse its ID on retries and saved setup.
Finish that record through the existing hire path.

**Reason and benefit**

Give setup a stable identity without coupling this refactor to computer
provisioning or a new execution backend.

**Breaking changes**

Incomplete setup now leaves an inactive agent record. Existing clients
can still create and hire agents without the new optional fields. The
sandbox authentication flow and API key support remain in place.

**Additional context**

Requested Core-only refactor. Related work: #15422 changes runner
defaults. This PR preserves those choices. No duplicate early-record
refactor was found in the issue and PR search.

## What Changed

- Save the agent when the user submits its name in both onboarding entry
points. Use a provisional adapter until the user chooses one. Reopen
name-only drafts at adapter selection.
- Add retry-safe setup creation and same-record hire completion. Keep
approval checks at final hire.
- Save and restore setup configuration in both onboarding entry points.
Show Finish setup for paused setup agents.
- Restrict setup changes to authorized board users. Block generic resume
until setup finishes.
- Add lifecycle, authorization, connection, and UI coverage. Update the
specification and feature maps.

## Verification

- Latest focused server run: 126 lifecycle and AI-connection tests
passed after the Apex fixes. An earlier broader run passed 301
lifecycle, instruction, adapter-validation, and AI-connection tests.
Earlier authentication, retry, cross-company, and direct-create
regressions also passed.
- Name-save verification: 336 tests across the wizard, naming dialog,
New agent, action-button, and wizard-step suites passed. Coverage
includes retry identity, repeated submission, closing during a save,
saved-model restoration, and a disabled process adapter.
- Shared, server, and UI TypeScript checks passed. The latest UI package
check and direct server TypeScript check passed. The server package
preparation command could not finish locally because Cargo is
unavailable; full build preparation runs in CI. Map checks, token gates,
and `git diff --check` passed; each pushed diff was scanned for
credentials and PII.
- All six onboarding browser tests passed together with installed Chrome
against a fresh throwaway instance after the Apex fixes. They include
numbered duplicate names, first-time Copilot without a connected
account, and Grok’s saved model. They verify early persistence in both
entry points, closing and reopening a name-only draft, same-record
adapter selection, sandbox sign-in, login reload, and failed-connection
gating. Provider responses are simulated; Core persistence is real.
- Regression coverage includes incomplete Copilot creation versus final
validation, Kimi settings through redacted responses, stale local
drafts, company switches during creation, saved environments, and
provider changes with personal/shared old bindings.
- Apex found five name-only setup gaps: numbered names, early Copilot
account validation, the Grok model default, same-name draft ownership,
and provisional runner restoration. The fixes preserve server names,
defer implicit account binding until hire, set Grok’s model, avoid
name-based reuse of unfinished records, and restore Auto for pending
adapter choices. UI, database, and browser regressions cover these
cases.
- Standard Greptile found a Grok provider mismatch in the new adapter
save. The save now uses ACPX with the Grok profile. Both the 129-test
dialog/setup run and a real-API browser regression passed after the fix.
The first browser attempt stopped on an ambiguous empty-roster button
selector; it now scopes the click to the toolbar.
- Moving the save point exposed a saved-model reset and timing
assumptions in the same-name and portal fixtures. The source choice now
preserves the restored model, and tests cover same-name recovery without
a second hire.
- Initial CI exposed direct-create compatibility, concurrent setup
effects, fixture assumptions, and a missing-fields assumption in the
provider-validation guard. These were fixed and verified locally. Review
findings were addressed with authorization, recovery,
credential-persistence, and restored-configuration regressions.
- The final desktop capture passed after host resource cleanup. A repeat
first failed before Core started because macOS had exhausted
shared-memory IDs. Standalone PostgreSQL initialization reproduced the
same error. Releasing only unattached control segments from this task’s
stopped test databases allowed the unchanged revision to pass. No Core
or system setting changed.
- Local database tests required port access outside the sandbox. Missing
declared development dependencies were supplied outside the branch. No
live provider sign-in was tested locally.
- [Full monorepo
CI](https://github.com/paperclipai/paperclip/actions/runs/39522434236)
is green on `82b82fec189a172a885d4dd565ae2f4a18c73efc`. Standard
Greptile scored 5/5 with zero new comments (check `118969424903`). The
subsequent Apex review also scored 5/5 with zero new comments (check
`118972866331`) on that same commit. All review threads are resolved.
- The separate Superagent Security scan returned neutral/inconclusive
because its pinned merge-base checkout failed or timed out. GitHub
returned 404 for check and suite rerun requests. This is not a
successful security scan; the CI workflow and other security checks
passed.

## Risks

- Users can leave inactive setup records behind, including after name
submission alone. These records cannot run until hire completes.
- Setup completion touches hiring and approval boundaries. Tests cover
retries, company isolation, creator permissions, and approval snapshots.
- An uncertain credential-save result retains its new secret reference
for retry. A confirmed client rejection removes only credentials staged
by that attempt.
- No schema migration or VM provisioning change is required.
Agent-computer integration is out of scope.

## Model Used

OpenAI Codex, based on GPT-6, with repository search, code editing, and
tool execution. The exact deployment ID and context window are not
exposed in this session.

## 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] Apex Greptile is also 5/5 on the same commit as standard review
and green CI
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-20 18:08:57 -07:00
1 parent d33b40a5c3
commit 79b2bf78dd
26 files changed
+1623 -153

No files matched your search

+40
View File
@@ -1308,6 +1308,46 @@ Invites tab does not hide this Cloud action.
## 10.3 Agents
The Core setup wizard creates its agent record when the user submits the agent
name, before adapter selection, authentication, and runtime configuration.
Until the adapter is chosen, the held record uses a provisional adapter
(process with no command when enabled) and `metadata.setupAdapterPending: true`. Resuming such a record
returns to adapter selection with automatic runner selection. Choosing an adapter
updates the same record and clears that marker. Held Copilot records can select
the adapter before connecting an account; account checks still apply at connection
testing and final hire. Setup preserves server-assigned names, including numbered
duplicates. Name-only lookup does not adopt another unfinished record. `POST /companies/:companyId/agents` accepts a board-only `setupId`
(a UUID used as the agent ID). The record stays paused with a `setup` hold;
repeating that request reuses the same unfinished record. It starts no lifecycle
preparation and creates no hire approval. Instructions, task-assignment membership,
and creation activity are applied at final hire. Normal create requests keep their
existing behavior.
The wizard saves configuration on that record through `PATCH /agents/:agentId`.
To finish, it submits the final configuration to
`POST /companies/:companyId/agent-hires` with `setupAgentId`. Core validates the
configuration and completes the same record. Companies that require approval
receive a hire approval for that final configuration; other agents enter normal
preparation and verification. A setup record cannot be resumed into execution,
receive API keys, or authenticate as an agent. Its standard grants apply at hire.
Authorized retries recover the completed record and its existing hire approval;
they cannot replace the submitted configuration. Both setup entry points save
configuration on the record so the roster can reopen it with the same runner.
Resuming setup loads the server configuration before autosaving local edits.
Its saved environment is used for login, connection verification, and final hire.
Changing harnesses drops incompatible saved AI connections. Setup runtime updates
carry the current connection choice rather than retaining a superseded binding.
Connection tests use the setup ID to restore redacted environment values under
the setup creator's permissions; shared connections are installed only at hire.
Changing Claude authentication modes removes the previous mode's credentials.
Held setup records may omit provider details such as the Copilot model; final
hire still requires a complete provider configuration. Kimi API model, protocol,
and endpoint choices are also stored as nonsecret `adapterConfig.kimiApi` settings
so they can be restored while environment values remain redacted.
Credentials remain available for retry when a save response is lost. A confirmed
client rejection removes only newly staged credentials from that attempt.
Sandbox authentication and saved AI connections keep their existing flows.
- `GET /companies/:companyId/agents`
- `POST /companies/:companyId/agents`
- `GET /agents/:agentId`
+12
View File
@@ -44,6 +44,18 @@ bypass hiring authorization. Source: [runner resolver](../../server/src/services
Policy: Agent creation/configuration uses action grants, resource policies and change
consent; some controls require a board actor.
The early wizard record (`setupId` on create) and completion of that record
(`setupAgentId` on hire) require a board actor and company-scoped creation
permission. Its creator can finish their own inactive setup with that permission;
changing another person's setup requires configuration permission.
Creation retries reuse only records owned by the caller. Returning a completed
record also requires the normal agent-read and configuration-read checks.
An unfinished record stays paused with a
`setup` hold and cannot receive API keys or authenticate with keys or JWTs.
Its standard grants apply at hire. Authorized completion retries recover the
same record and approval without changing the submitted configuration.
Normal agent-authored hiring keeps its existing approval policy.
The creation boundary resolves the request-level runner before adapter validation
and credential normalization. Omitted/automatic new Codex selection uses the
qualified target's default; explicit Legacy is retained. Disabled adapters and
+6
View File
@@ -31,6 +31,12 @@ Agent access: [companion](../agent-access-map/agents.md).
Use New agent from the roster or `/agents/new`.
Submitting the name creates an inactive record before adapter selection or account setup.
A name-only draft returns to adapter selection when reopened.
Save & exit keeps its configuration; Finish setup on the agent returns to the
same record. Final submission requests approval when required, then follows the
existing preparation and verification lifecycle.
### `agent-configuration`
Open `/agents/:agentId` and the configuration/instruction surfaces it exposes.
+1 -1
View File
@@ -669,7 +669,7 @@ export type RoutineRunSource = (typeof ROUTINE_RUN_SOURCES)[number];
// "import" marks agents parked by a company import (safety default) so the UI
// can explain the pause and offer a scoped bulk-resume; "system" remains the
// reason for platform-managed pauses (plugins, built-ins).
export const PAUSE_REASONS = ["manual", "budget", "system", "company_archived", "import"] as const;
export const PAUSE_REASONS = ["manual", "budget", "system", "company_archived", "import", "setup"] as const;
export type PauseReason = (typeof PAUSE_REASONS)[number];
export const PROJECT_COLORS = [
+4 -2
View File
@@ -84,6 +84,7 @@ export const agentRuntimeConfigSchema = z.object({
});
export const createAgentSchema = z.object({
setupId: z.string().guid().optional(),
name: z.string().min(1),
role: z.enum(AGENT_ROLES).optional().default("general"),
title: z.string().optional().nullable(),
@@ -139,7 +140,8 @@ export const builtInAgentResetSchema = z.object({
export type BuiltInAgentReset = z.infer<typeof builtInAgentResetSchema>;
export const createAgentHireSchema = createAgentSchema.extend({
export const createAgentHireSchema = createAgentSchema.omit({ setupId: true }).extend({
setupAgentId: z.string().guid().optional(),
sourceIssueId: z.string().guid().optional().nullable(),
sourceIssueIds: z.array(z.string().guid()).optional(),
// Agent-authored hires may explicitly request the caller's native runner
@@ -150,7 +152,7 @@ export const createAgentHireSchema = createAgentSchema.extend({
export type CreateAgentHire = z.infer<typeof createAgentHireSchema>;
export const updateAgentSchema = objectWithoutDefaults(
createAgentSchema.omit({ permissions: true, onboardingFirstAgent: true }),
createAgentSchema.omit({ permissions: true, onboardingFirstAgent: true, setupId: true }),
)
.partial()
.extend({
@@ -31,7 +31,7 @@ function createSelectChain(rowsForTable: (table: unknown) => unknown[]) {
}
function createDbState(input: {
agent: { id: string; companyId: string; status?: string };
agent: { id: string; companyId: string; status?: string; lifecycleHolds?: string[] };
agentKey?: { id: string; agentId: string; companyId: string; keyHash: string; responsibleUserId?: string | null };
run?: { id: string; companyId: string; agentId: string; responsibleUserId?: string | null;
status?: string; contextSnapshot?: Record<string, unknown>; resultJson?: Record<string, unknown> };
@@ -41,6 +41,7 @@ function createDbState(input: {
id: input.agent.id,
companyId: input.agent.companyId,
status: input.agent.status ?? "active",
lifecycleHolds: input.agent.lifecycleHolds ?? [],
};
const keyRow = input.agentKey
? {
@@ -303,6 +304,19 @@ describe("agent auth middleware", () => {
expect(res.body.error).toContain(error);
});
it.each(["key", "jwt"])("rejects unfinished setup authentication with a %s", async kind => {
const companyId = randomUUID();
const agentId = randomUUID();
const token = kind === "jwt" ? createLocalAgentJwt(agentId, companyId, "codex_local", randomUUID(), "user-1") : "pcp_setup-test-token";
const { db } = createDbState({
agent: { id: agentId, companyId, status: "paused", lifecycleHolds: ["setup"] },
...(kind === "key" ? { agentKey: { id: randomUUID(), agentId, companyId, keyHash: hashToken(token), responsibleUserId: "user-1" } } : {}),
});
const res = await request(createApp(db, "local_trusted")).get("/actor").set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(401);
expect(res.body.error).toContain("setup is incomplete");
});
it("rejects an agent JWT when the agent record belongs to another company", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -2177,6 +2177,61 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
}
});
it("saves a restricted shared connection during setup and installs it only when setup finishes", async () => {
const { requireServerAdapter } = await import("../adapters/index.js");
const setupId = randomUUID();
const account = await service.save(companyId, "alice", { provider: "anthropic", method: "api_key", ownership: "shared", name: "Shared setup", apiKey: "fixture", agentIds: [], allAgents: false }, "fixture-setup-key");
const selected = { provider: "anthropic", method: "api_key", mode: "shared", ...account } as const;
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] }; next(); });
app.use("/api", agentRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const initial = await request(app).post(`/api/companies/${companyId}/agents`).send({ setupId, name: "Shared setup agent", adapterType: "claude_local", runner: "legacy" });
expect(initial.status, JSON.stringify(initial.body)).toBe(201);
const probe = vi.spyOn(requireServerAdapter("claude_local"), "testEnvironment");
const providerRequest = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 }));
try {
const saved = await request(app).patch(`/api/agents/${setupId}`).send({ runtimeConfig: { aiConnection: selected } });
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
expect(saved.body).toMatchObject({ pauseReason: "setup", runtimeConfig: { aiConnection: selected } });
expect(probe).not.toHaveBeenCalled();
probe.mockResolvedValue({ adapterType: "claude_local", status: "pass", checks: [], testedAt: new Date().toISOString() });
const tested = await request(app).post(`/api/companies/${companyId}/adapters/claude_local/test-environment`).send({
agentId: setupId, runner: "legacy", adapterConfig: {}, aiConnection: selected,
});
expect(tested.status, JSON.stringify(tested.body)).toBe(200);
expect(probe).toHaveBeenCalledTimes(1);
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.targetId, setupId))).toHaveLength(0);
const finished = await request(app).post(`/api/companies/${companyId}/agent-hires`).send({
setupAgentId: setupId, name: "Shared setup agent", adapterType: "claude_local", runner: "legacy", runtimeConfig: { aiConnection: selected },
});
expect(finished.status, JSON.stringify(finished.body)).toBe(201);
expect(finished.body.agent.id).toBe(setupId);
expect(await db.select().from(toolConnectionInstalls).where(and(eq(toolConnectionInstalls.connectionId, account.connectionId), eq(toolConnectionInstalls.targetId, setupId)))).toHaveLength(1);
} finally { probe.mockRestore(); providerRequest.mockRestore(); }
});
it.each([["responsible_user", "codex_local"], ["shared", "codex_local"], ["responsible_user", "claude_local"]] as const)("drops a superseded saved %s connection when %s setup changes authentication", async (mode, adapterType) => {
const setupId = randomUUID();
await db.insert(agents).values({ id: setupId, companyId, name: "Switch provider", createdByUserId: "alice",
adapterType, status: "paused", lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"],
runtimeConfig: { aiConnection: mode === "shared"
? { provider: "openai", method: "api_key", mode, connectionId: randomUUID(), grantId: randomUUID() }
: { provider: adapterType === "claude_local" ? "anthropic" : "openai", method: "subscription", mode } },
});
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] }; next(); });
app.use("/api", agentRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const saved = await request(app).patch(`/api/agents/${setupId}`).send({ adapterType: "claude_local", runner: "legacy",
adapterConfig: {}, runtimeConfig: { heartbeat: { enabled: false } },
});
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
expect(saved.body.runtimeConfig).not.toHaveProperty("aiConnection");
const [row] = await db.select().from(agents).where(eq(agents.id, setupId));
expect(row.runtimeConfig).not.toHaveProperty("aiConnection");
});
it("creates and hires agents with an authorized restricted shared connection", async () => {
const { agentRoutes } = await import("../routes/agents.js");
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
+2
View File
@@ -87,6 +87,8 @@ export type AgentConfigurationPatch = Partial<Pick<typeof agents.$inferInsert, t
export type AgentHireRecord = Pick<typeof agents.$inferSelect, "id" | "companyId" | "adapterType" | "adapterConfig" | "permissions" | "metadata" | "status">;
export interface CreateAgentOptions {
setupOnly?: boolean;
setupAgentId?: string;
runnerResolved?: boolean;
responsibleUserId?: string | null;
createdByUserId?: string | null;
+8
View File
@@ -383,6 +383,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
return;
}
if (agentRecord.lifecycleHolds?.includes("setup")) {
next(unauthorized("Agent setup is incomplete and cannot authenticate"));
return;
}
if (agentRecord.status === "terminated") {
next(unauthorized("Agent is terminated and cannot authenticate"));
return;
@@ -498,6 +502,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
next(unauthorized("Agent record is missing or belongs to another company; obtain fresh credentials and retry"));
return;
}
if (agentRecord.lifecycleHolds?.includes("setup")) {
next(unauthorized("Agent setup is incomplete and cannot authenticate"));
return;
}
if (agentRecord.status === "terminated") {
next(unauthorized("Agent is terminated and cannot authenticate"));
return;
@@ -106,6 +106,190 @@ const support = await getEmbeddedPostgresTestSupport();
expect(agent).not.toHaveProperty("lifecycleOperation");
});
it("keeps early setup inactive and completes the same record without renaming it", async () => {
const runHost = vi.fn(async () => "complete" as const);
const work = worker(runHost);
const lifecycle = createAgentLifecycle(db);
const id = randomUUID();
const input = { id, name: "Early agent", adapterType: "process" };
const first = await lifecycle.requestHire(companyId, input, { setupOnly: true });
const retry = await lifecycle.requestHire(companyId, input, { setupOnly: true });
expect(first.id).toBe(id);
expect(retry.id).toBe(id);
await work.process(id);
expect(runHost).not.toHaveBeenCalled();
expect(await current(id)).toMatchObject({ status: "paused", lifecycleState: "paused", lifecycleHolds: ["setup"] });
expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, id))).toHaveLength(0);
await expect(agentConfiguration(db).createApiKey(id, "Incomplete setup")).rejects.toThrow("Finish agent setup");
await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup");
const finished = await lifecycle.requestHire(companyId, { id, name: "Early agent", adapterType: "process", status: "idle" }, { setupAgentId: id });
expect(finished).toMatchObject({ id, name: "Early agent" });
await work.process(id);
expect(await current(id)).toMatchObject({ status: "idle", lifecycleState: "ready", lifecycleHolds: [] });
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
await expect(lifecycle.requestHire(companyId, { id, name: "Early agent" }, { setupAgentId: id })).rejects.toThrow("no longer available");
});
it("freezes the final setup configuration for hire approval", async () => {
const lifecycle = createAgentLifecycle(db);
const id = randomUUID();
await lifecycle.requestHire(companyId, { id, name: "Approval setup", status: "paused", pauseReason: "setup", adapterType: "process" }, { setupOnly: true });
await lifecycle.requestHire(companyId, { id, name: "Approval setup", status: "pending_approval", adapterType: "process", adapterConfig: { command: "verified-command" } }, { setupAgentId: id });
expect(await current(id)).toMatchObject({ lifecycleState: "pending_approval", lifecycleHolds: [], adapterConfig: { command: "verified-command" } });
await expect(lifecycle.updateConfiguration(id, { adapterConfig: { command: "changed" } })).rejects.toThrow("cannot be changed before board approval");
});
it("rejects completing a foreign setup or adopting an ordinary paused agent", async () => {
const lifecycle = createAgentLifecycle(db);
const setup = await lifecycle.requestHire(companyId, { name: "Scoped setup", status: "paused", pauseReason: "setup" }, { setupOnly: true });
const otherCompanyId = randomUUID();
await db.insert(companies).values({ id: otherCompanyId, name: "Other", issuePrefix: `S${otherCompanyId.slice(0, 7)}` });
await expect(lifecycle.requestHire(otherCompanyId, { name: "Foreign" }, { setupAgentId: setup.id })).rejects.toThrow("no longer available");
const paused = await hire("paused");
await expect(lifecycle.requestHire(companyId, { name: "Paused" }, { setupAgentId: paused.id })).rejects.toThrow("no longer available");
expect((await current(setup.id)).lifecycleHolds).toEqual(["setup"]);
});
it("creates a board-owned setup before requesting approval for its final configuration", async () => {
await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
await db.insert(companyMemberships).values(["board", "other-creator"].map(principalId => ({ companyId, principalType: "user", principalId, membershipRole: "member" })));
await db.insert(principalPermissionGrants).values(["board", "other-creator"].map(principalId => ({ companyId, principalType: "user", principalId, permissionKey: "agents:create" })));
function app(actor: Express.Request["actor"]) {
const value = express(); value.use(express.json());
value.use((req, _res, next) => { req.actor = actor; next(); });
value.use("/api", agentRoutes(db)); value.use(errorHandler); return value;
}
const board: Express.Request["actor"] = { type: "board", source: "session", userId: "board", companyIds: [companyId],
memberships: [{ companyId, membershipRole: "member", status: "active" }] };
const setupId = randomUUID();
const body = { setupId, name: "Wizard agent", adapterType: "process" };
const url = `/api/companies/${companyId}/agents`;
const attempts = await Promise.all(Array.from({ length: 4 }, () => request(app(board)).post(url).send(body)));
for (const attempt of attempts) {
expect(attempt.status, JSON.stringify(attempt.body)).toBe(201);
expect(attempt.body).toMatchObject({ id: setupId, pauseReason: "setup", lifecycleState: "paused" });
}
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
expect(await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "agent")))).toHaveLength(0);
await request(app({ ...board, userId: "other-creator" })).post(url).send(body).expect(404);
const other = await createAgentLifecycle(db).requestHire(companyId, {
name: "Other creator", adapterType: "process", status: "paused", adapterConfig: { command: "private-command" },
runtimeConfig: { privateSetting: "private-value" },
}, { createdByUserId: "other-creator" });
const configProbe = await request(app(board)).post(url).send({ ...body, setupId: other.id });
expect(configProbe.status).toBe(404);
expect(configProbe.body).not.toHaveProperty("adapterConfig");
expect(configProbe.body).not.toHaveProperty("runtimeConfig");
expect(await db.select().from(approvals).where(eq(approvals.companyId, companyId))).toHaveLength(0);
const saved = await request(app(board)).patch(`/api/agents/${setupId}`).send({ adapterConfig: { command: "draft-command", env: { SETUP_LABEL: { type: "plain", value: "saved-value" } } } });
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
const probe = vi.spyOn(requireServerAdapter("process"), "testEnvironment").mockResolvedValue({ adapterType: "process", status: "pass", checks: [], testedAt: new Date().toISOString() });
try {
const probeBody = { agentId: setupId, adapterConfig: saved.body.adapterConfig };
const testUrl = `/api/companies/${companyId}/adapters/process/test-environment`;
const tested = await request(app(board)).post(testUrl).send(probeBody);
expect(tested.status, JSON.stringify(tested.body)).toBe(200);
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ config: expect.objectContaining({ env: { SETUP_LABEL: "saved-value" } }) }));
await request(app({ ...board, userId: "other-creator" })).post(testUrl).send(probeBody).expect(403);
expect(probe).toHaveBeenCalledTimes(1);
} finally { probe.mockRestore(); }
await request(app({ ...board, userId: "other-creator" })).patch(`/api/agents/${setupId}`).send({ adapterConfig: { command: "foreign-command" } }).expect(403);
const denied = await request(app({ ...board, companyIds: [], memberships: [] })).post(url).send({ ...body, setupId: randomUUID() });
expect([403, 404]).toContain(denied.status);
await request(app({ type: "agent", source: "agent_key", companyId, agentId: setupId })).post(url).send({ ...body, setupId: randomUUID() }).expect(403);
const finished = await request(app(board)).post(`/api/companies/${companyId}/agent-hires`).send({
setupAgentId: setupId, name: "Wizard agent", adapterType: "process", adapterConfig: { command: "verified-command", env: { SETUP_LABEL: { type: "plain", value: "***REDACTED***" } } },
});
expect(finished.status, JSON.stringify(finished.body)).toBe(201);
expect(finished.body.agent).toMatchObject({ id: setupId, name: "Wizard agent", lifecycleState: "pending_approval" });
expect(finished.body.approval.payload).toMatchObject({ agentId: setupId, adapterConfig: { command: "verified-command" } });
expect((await current(setupId)).adapterConfig.env).toEqual({ SETUP_LABEL: { type: "plain", value: "saved-value" } });
expect(await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, setupId)))).toHaveLength(1);
const finishBody = { setupAgentId: setupId, name: "Changed on retry", adapterType: "process", adapterConfig: { command: "unapproved-command" } };
const hireUrl = `/api/companies/${companyId}/agent-hires`;
const retries = await Promise.all(Array.from({ length: 3 }, () => request(app(board)).post(hireUrl).send(finishBody)));
for (const retry of retries) {
expect(retry.status, JSON.stringify(retry.body)).toBe(200);
expect(retry.body.agent).toMatchObject({ id: setupId, name: "Wizard agent", adapterConfig: { command: "verified-command" } });
expect(retry.body.approval.id).toBe(finished.body.approval.id);
}
await request(app({ ...board, userId: "other-creator" })).post(hireUrl).send(finishBody).expect(403);
const reopened = await request(app(board)).post(url).send(body);
expect(reopened.status).toBe(201);
expect(reopened.body).toMatchObject({ id: setupId, status: "pending_approval" });
await request(app({ ...board, userId: "other-creator" })).post(url).send(body).expect(404);
await db.delete(approvals).where(eq(approvals.id, finished.body.approval.id));
const recovered = await request(app(board)).post(hireUrl).send(finishBody);
expect(recovered.status, JSON.stringify(recovered.body)).toBe(200);
expect(recovered.body.approval.payload).toMatchObject({ name: "Wizard agent", adapterConfig: { command: "verified-command" } });
await approvalService(db).approve(recovered.body.approval.id, "board");
const approvedRetry = await request(app(board)).post(hireUrl).send(finishBody);
expect(approvedRetry.status).toBe(200);
expect(approvedRetry.body.approval.status).toBe("approved");
expect(await db.select().from(approvals).where(eq(approvals.companyId, companyId))).toHaveLength(1);
});
it("restores hidden setup values when a native Codex record uses the Codex onboarding choice", async () => {
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board", membershipRole: "owner" });
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "board", companyIds: [companyId] }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
const setupId = randomUUID();
const created = await request(app).post(`/api/companies/${companyId}/agents`).send({ setupId, name: "Native saved setup",
adapterType: "paperclip_runner", adapterConfig: { provider: "codex", env: { SETUP_LABEL: { type: "plain", value: "saved-native-value" } } },
});
expect(created.status, JSON.stringify(created.body)).toBe(201);
expect(created.body.adapterConfig.env.SETUP_LABEL).toEqual({ type: "plain", value: "***REDACTED***" });
const probe = vi.spyOn(requireServerAdapter("paperclip_runner"), "testEnvironment").mockResolvedValue({ adapterType: "paperclip_runner", status: "pass", checks: [], testedAt: new Date().toISOString() });
try {
const tested = await request(app).post(`/api/companies/${companyId}/adapters/codex_local/test-environment`).send({
agentId: setupId, runner: "paperclip", adapterConfig: created.body.adapterConfig,
});
expect(tested.status, JSON.stringify(tested.body)).toBe(200);
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ config: expect.objectContaining({ env: { SETUP_LABEL: "saved-native-value" } }) }));
} finally { probe.mockRestore(); }
const finished = await request(app).post(`/api/companies/${companyId}/agent-hires`).send({ setupAgentId: setupId, name: "Native saved setup",
adapterType: "codex_local", runner: "paperclip", adapterConfig: created.body.adapterConfig,
});
expect(finished.status, JSON.stringify(finished.body)).toBe(201);
expect((await current(setupId)).adapterConfig.env).toEqual({ SETUP_LABEL: { type: "plain", value: "saved-native-value" } });
});
it("holds an incomplete Copilot setup but still requires a model at final hire", async () => {
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board", membershipRole: "owner" });
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "board", companyIds: [companyId] }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
const setupId = randomUUID();
const body = { name: "Copilot setup", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot" } };
const created = await request(app).post(`/api/companies/${companyId}/agents`).send({ ...body, setupId });
expect(created.status, JSON.stringify(created.body)).toBe(201);
expect(created.body).toMatchObject({ id: setupId, pauseReason: "setup", lifecycleState: "paused" });
const finished = await request(app).post(`/api/companies/${companyId}/agent-hires`).send({ ...body, setupAgentId: setupId });
expect(finished.status, JSON.stringify(finished.body)).toBe(422);
expect(finished.body.details?.code ?? finished.body.code).toBe("paperclip_runner_acpx_model_required");
expect((await current(setupId)).lifecycleHolds).toContain("setup");
await request(app).post(`/api/companies/${companyId}/agents`).send(body).expect(422);
});
it("lets a held name-only record choose Copilot before connecting an account", async () => {
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board", membershipRole: "owner" });
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "board", companyIds: [companyId] }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
const setupId = randomUUID();
await request(app).post(`/api/companies/${companyId}/agents`).send({ setupId, name: "Name-only Copilot", adapterType: "process", metadata: { setupAdapterPending: true } }).expect(201);
const config = { provider: "acpx", acpxAgent: "copilot" };
const saved = await request(app).patch(`/api/agents/${setupId}`).send({ adapterType: "paperclip_runner", adapterConfig: config, metadata: { setupAdapterPending: false } });
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
expect(saved.body).toMatchObject({ id: setupId, pauseReason: "setup", adapterConfig: config });
expect(saved.body.runtimeConfig.aiConnection).toBeUndefined();
const finished = await request(app).post(`/api/companies/${companyId}/agent-hires`).send({ setupAgentId: setupId, name: "Name-only Copilot", adapterType: "paperclip_runner", adapterConfig: { ...config, model: "gpt-5.6-luna" } });
expect(finished.status, JSON.stringify(finished.body)).toBe(422);
expect(finished.body.error).toMatch(/connection|account/i);
expect((await current(setupId)).lifecycleHolds).toContain("setup");
});
it("rolls back a hire and its grants when hire initialization fails", async () => {
const effects = createAgentLifecycleEffects();
const lifecycle = createLifecycleCommands(db, { ...effects,
@@ -21,6 +21,7 @@ export function createLifecycleStore(db: Db, effects: LifecycleEffects): Lifecyc
const [agent] = await tx.select().from(agents).where(eq(agents.id, id)).for("update", command === "reconcile" ? { skipLocked: true } : undefined);
if (!agent) return null;
if (options.version !== undefined && options.version !== agent.lifecycleVersion) return null;
if (command === "resume" && agent.lifecycleHolds.includes("setup")) throw new AgentLifecycleConflict("Finish agent setup before resuming");
if (command === "complete" && (!options.owner || !agent.lifecycleOperation?.leaseUntil || agent.lifecycleOperation?.leaseOwner !== options.owner ||
Date.parse(agent.lifecycleOperation?.leaseUntil ?? "") <= Date.now() ||
!agent.lifecycleOperation.hostComplete || agent.lifecycleRequiredPluginIds === null ||
@@ -14,25 +14,43 @@ export function agentRecords(db: Db, effects: LifecycleEffects) {
if (Object.keys(data).some(key => key.startsWith("lifecycle"))) throw conflict("Lifecycle fields belong to the lifecycle module");
return effects.transaction(db, companyId, async (txDb) => {
const tx = txDb;
const setupId = options?.setupAgentId ?? (options?.setupOnly ? data.id : undefined);
const [setup] = setupId ? await tx.select().from(agents).where(eq(agents.id, setupId)).for("update") : [];
if (setup && setup.companyId !== companyId) {
throw conflict("Agent setup is no longer available");
}
if (options?.setupOnly && setup) {
if (setup.createdByUserId !== (options.createdByUserId ?? null)) throw notFound("Agent setup not found");
const existing = await agentRecordQueries(txDb).getById(setup.id);
if (!existing) throw notFound("Agent setup not found");
return existing;
}
if (setup && (setup.lifecycleState !== "paused" || !setup.lifecycleHolds.includes("setup"))) {
throw conflict("Agent setup is no longer available");
}
if (options?.setupAgentId && !setup) throw notFound("Agent setup not found");
const prepared = await effects.prepareHire(txDb, companyId, data, options);
const created = await tx
.insert(agents)
.values({
...prepared,
createdByUserId: options?.createdByUserId ?? null,
lifecycleState: data.status === "pending_approval" ? "pending_approval" : data.status === "terminated" ? "terminated" : data.status === "paused" ? "paused" : "preparing",
lifecycleVersion: 1,
lifecycleHolds: data.status === "paused" ? [data.pauseReason ?? "manual"] : [],
pauseReason: data.status === "paused" ? data.pauseReason ?? "manual" : null,
pausedAt: data.status === "paused" ? new Date() : null,
lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0, resumeState: "preparing", responsibleUserId: options?.responsibleUserId ?? options?.createdByUserId },
status: data.status === "pending_approval" ? "pending_approval" : data.status === "terminated" ? "terminated" : "paused",
companyId,
})
const status = options?.setupOnly ? "paused" : data.status;
const pauseReason = options?.setupOnly ? "setup" : data.pauseReason;
const values: typeof agents.$inferInsert = {
...prepared,
createdByUserId: setup?.createdByUserId ?? options?.createdByUserId ?? null,
lifecycleState: status === "pending_approval" ? "pending_approval" : status === "terminated" ? "terminated" : status === "paused" ? "paused" : "preparing",
lifecycleVersion: setup ? setup.lifecycleVersion + 1 : 1,
lifecycleHolds: status === "paused" ? [pauseReason ?? "manual"] : [],
pauseReason: status === "paused" ? pauseReason ?? "manual" : null,
pausedAt: status === "paused" ? new Date() : null,
lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0, resumeState: "preparing" as const, responsibleUserId: options?.responsibleUserId ?? options?.createdByUserId },
status: status === "pending_approval" ? "pending_approval" : status === "terminated" ? "terminated" : "paused",
companyId,
};
const created = await (options?.setupAgentId
? tx.update(agents).set({ ...values, updatedAt: new Date() }).where(eq(agents.id, options.setupAgentId))
: tx.insert(agents).values(values))
.returning()
.then((rows) => rows[0]);
await effects.initializeHire(txDb, created, options);
if (created.status !== "pending_approval" && created.status !== "terminated") {
if (!options?.setupOnly && created.status !== "pending_approval" && created.status !== "terminated") {
await effects.recordCreation(txDb, companyId, created.id);
}
const normalizedCreated = await agentRecordQueries(txDb).getById(created.id);
+103 -25
View File
@@ -1785,6 +1785,17 @@ export function agentRoutes(
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
async function assertCanConfigureAgentSetup(req: Request, targetAgent: {
id: string; companyId: string; createdByUserId?: string | null; lifecycleState?: string; pauseReason?: string | null;
}, allowCompleted = false) {
if (req.actor.type === "board" && req.actor.userId && targetAgent.createdByUserId === req.actor.userId
&& (allowCompleted || (targetAgent.lifecycleState === "paused" && targetAgent.pauseReason === "setup"))) {
await assertCanCreateAgentsForCompany(req, targetAgent.companyId);
return;
}
await assertCanUpdateAgent(req, targetAgent);
}
async function assertCanReadAgent(req: Request, targetAgent: { id: string; companyId: string }) {
if (!hasCompanyAccess(req, targetAgent.companyId)) {
throw notFound("Agent not found");
@@ -3169,8 +3180,9 @@ export function agentRoutes(
if (savedAgentId) {
if (!savedAgent) return;
if (savedAgent.companyId !== companyId) throw notFound("Agent not found");
await assertCanUpdateAgent(req, savedAgent);
await assertCanConfigureAgentSetup(req, savedAgent);
}
const testingSetup = savedAgent?.lifecycleState === "paused" && savedAgent.pauseReason === "setup";
const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined);
const requestedEnvironmentId = await resolveAdapterTestEnvironmentId(
companyId,
@@ -3192,14 +3204,7 @@ export function agentRoutes(
// the probe treats "***REDACTED***" as a value to persist.
let adapterConfigForTest = inputAdapterConfig;
if (savedAgent) {
const providerAdapter = savedAgent.adapterType === "paperclip_runner"
? inputAdapterConfig.provider === "codex"
? "codex_local"
: inputAdapterConfig.provider === "acpx" && inputAdapterConfig.acpxAgent === "claude"
? "claude_local"
: null
: null;
const canRestoreEnv = savedAgent.adapterType === type || providerAdapter === type;
const canRestoreEnv = agentHarnessType(savedAgent.adapterType, savedAgent.adapterConfig) === agentHarnessType(type, inputAdapterConfig);
// Permit testing a prospective adapter switch, but do not transfer
// hidden values from the saved adapter into an unrelated harness.
if (!canRestoreEnv && Object.values(parseObject(inputAdapterConfig.env)).some(value => {
@@ -3227,7 +3232,7 @@ export function agentRoutes(
const adapter = requireServerAdapter(type);
if (!retainsSavedExecution) await assertSelectableAdapterType(type, adapterConfigForTest);
if (poolBinding?.mode === "router") {
await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, adapterConfigForTest, poolBinding, requestedEnvironmentId, false, !savedAgentId);
await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, adapterConfigForTest, poolBinding, requestedEnvironmentId, false, !savedAgentId || testingSetup);
res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] });
return;
}
@@ -3376,7 +3381,7 @@ export function agentRoutes(
effectiveAdapterConfig.apiKey = req.body.testCredentials.API_SERVER_KEY;
}
}
const result = aiBinding ? await withManagedAiProbe(db, { companyId, agentId: req.body.agentId ?? "", responsibleUserId: responsibleUserForAiRequest(req), adapterType: type, binding: aiBinding, config: effectiveAdapterConfig, allowUninstalledPersonal: !req.body.agentId, allowUninstalledShared: !req.body.agentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, aiBinding) }, async managed => {
const result = aiBinding ? await withManagedAiProbe(db, { companyId, agentId: req.body.agentId ?? "", responsibleUserId: responsibleUserForAiRequest(req), adapterType: type, binding: aiBinding, config: effectiveAdapterConfig, allowUninstalledPersonal: !savedAgentId || testingSetup, allowUninstalledShared: (!savedAgentId || testingSetup) && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, aiBinding) }, async managed => {
const managedAiCredentialHome = type === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home
? path.join(managed.home, "provider") : undefined;
const tested = await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, managedAiCredentialHome, executionTarget, environmentName }, aiBinding, managed, savedAgentId ?? undefined);
@@ -4274,7 +4279,7 @@ export function agentRoutes(
// adapter-config secret lands in the activity log.
const hireFingerprint = (body: unknown): string => sha256Digest(body);
router.post("/companies/:companyId/agent-hires", validate(createAgentHireSchema), async (req, res) => {
const handleAgentHire = async (req: Request, res: Response) => {
const companyId = req.params.companyId as string;
await assertCanCreateAgentsForCompany(req, companyId);
const sourceIssueIds = parseSourceIssueIds(req.body);
@@ -4295,11 +4300,49 @@ export function agentRoutes(
onboardingFirstAgent: hireOnboardingFirstAgent,
// This intent flag is consumed below and must never reach agent create.
inheritRuntimeFrom,
setupAgentId,
runner: hireRunner,
...hireInput
} = req.body;
const hiredAgentId = randomUUID();
if (setupAgentId) {
assertBoard(req);
if (!req.actor.userId) throw forbidden("User identity required for agent setup");
const setupAgent = await svc.getById(setupAgentId);
if (!setupAgent || setupAgent.companyId !== companyId) throw notFound("Agent setup not found");
await assertCanConfigureAgentSetup(req, setupAgent, true);
if (!setupAgent.lifecycleHolds.includes("setup")) {
if (!(await assertAgentReadAllowed(req, res, setupAgent))) return;
await assertCanReadConfigurations(req, companyId);
if (["terminated", "rejected", "deleting"].includes(setupAgent.lifecycleState)) throw conflict("Agent setup is no longer available");
const priorApprovals = await approvalsSvc.list(companyId, undefined,
sql`type = 'hire_agent' AND payload ->> 'agentId' = ${setupAgentId}`);
let approval = priorApprovals.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? null;
if (!approval && setupAgent.status === "pending_approval") {
const adapterConfig = redactEventPayload(setupAgent.adapterConfig) ?? {};
const runtimeConfig = redactEventPayload(setupAgent.runtimeConfig) ?? {};
const desiredSkills = readPaperclipSkillSyncPreference(setupAgent.adapterConfig).desiredSkills;
approval = await approvalsSvc.create(companyId, {
type: "hire_agent", requestedByAgentId: null, requestedByUserId: req.actor.userId ?? null,
status: "pending", payload: {
name: setupAgent.name, role: setupAgent.role, title: setupAgent.title, icon: setupAgent.icon,
appearance: setupAgent.appearance, reportsTo: setupAgent.reportsTo, capabilities: setupAgent.capabilities,
adapterType: setupAgent.adapterType, adapterConfig, runtimeConfig, desiredSkills,
budgetMonthlyCents: setupAgent.budgetMonthlyCents, metadata: redactEventPayload(setupAgent.metadata ?? {}) ?? {},
agentId: setupAgent.id, requestedByAgentId: null,
requestedConfigurationSnapshot: { adapterType: setupAgent.adapterType, adapterConfig, runtimeConfig, desiredSkills },
}, decisionNote: null, decidedByUserId: null, decidedAt: null, updatedAt: new Date(),
});
}
res.status(200).json({ agent: redactAgentRowForResponse(setupAgent), approval, idempotent: true });
return;
}
if (hireInput.adapterConfig && agentHarnessType(hireInput.adapterType ?? "process", hireInput.adapterConfig)
=== agentHarnessType(setupAgent.adapterType, setupAgent.adapterConfig)) {
hireInput.adapterConfig = restoreRedactedAgentEnv(hireInput.adapterConfig, setupAgent.adapterConfig);
}
}
const hiredAgentId = setupAgentId ?? randomUUID();
const requestedHireConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, hireInput.runtimeConfig);
assertNoAgentAdapterConfigMutation(req, requestedHireConfig);
@@ -4459,6 +4502,7 @@ export function agentRoutes(
lastHeartbeatAt: null,
},
{
...(setupAgentId ? { setupAgentId } : {}),
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
runnerResolved: true,
responsibleUserId: responsibleUserForAiRequest(req),
@@ -4609,6 +4653,12 @@ export function agentRoutes(
? await withHireRunLock(`${companyId}:${runId}`, performHire)
: await performHire();
res.status(outcome.status).json(outcome.body);
};
router.post("/companies/:companyId/agent-hires", validate(createAgentHireSchema), async (req, res) => {
if (req.body.setupAgentId) {
await withHireRunLock(`setup:${req.params.companyId}:${req.body.setupAgentId}`, () => handleAgentHire(req, res));
} else await handleAgentHire(req, res);
});
router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => {
@@ -4624,7 +4674,11 @@ export function agentRoutes(
res.status(404).json({ error: "Company not found" });
return;
}
if (company.requireBoardApprovalForNewAgents) {
if (req.body.setupId) {
assertBoard(req);
if (!req.actor.userId) throw forbidden("User identity required for agent setup");
}
if (company.requireBoardApprovalForNewAgents && !req.body.setupId) {
throw conflict(
"Direct agent creation requires board approval. Use POST /api/companies/:companyId/agent-hires to create a pending hire approval.",
);
@@ -4643,10 +4697,11 @@ export function agentRoutes(
// The onboarding marker is not an agent column. The server consumes it to
// seed the chief-of-staff persona; it never reaches the insert values.
onboardingFirstAgent: createOnboardingFirstAgent,
setupId,
runner: createRunner,
...createInput
} = req.body;
const agentId = randomUUID();
const agentId = setupId ?? randomUUID();
const requestedCreateConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, createInput.runtimeConfig);
assertNoAgentAdapterConfigMutation(req, requestedCreateConfig);
@@ -4657,7 +4712,7 @@ export function agentRoutes(
createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType, createInput.adapterConfig);
const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, createInput.runtimeConfig);
await assertFreshPaperclipRunnerProvider(
if (!setupId) await assertFreshPaperclipRunnerProvider(
companyId,
createInput.adapterType,
rawCreateAdapterConfig,
@@ -4712,7 +4767,7 @@ export function agentRoutes(
});
const managedBinding = normalizedRuntimeConfig.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedRuntimeConfig.aiConnection) : undefined;
const managedConnection = managedBinding ? await validateManagedAgentBinding(req, companyId, agentId, createInput.adapterType, normalizedAdapterConfig, managedBinding, createInput.defaultEnvironmentId, false, true) : undefined;
const managedConnection = managedBinding && !setupId ? await validateManagedAgentBinding(req, companyId, agentId, createInput.adapterType, normalizedAdapterConfig, managedBinding, createInput.defaultEnvironmentId, false, true) : undefined;
const createdAgent = await svcLifecycle.requestHire(
companyId,
{
@@ -4725,6 +4780,7 @@ export function agentRoutes(
lastHeartbeatAt: null,
},
{
...(setupId ? { setupOnly: true } : {}),
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
runnerResolved: true,
responsibleUserId: responsibleUserForAiRequest(req),
@@ -4739,6 +4795,14 @@ export function agentRoutes(
},
},
);
if (setupId) {
if (!createdAgent.lifecycleHolds.includes("setup")) {
if (!(await assertAgentReadAllowed(req, res, createdAgent))) return;
await assertCanReadConfigurations(req, companyId);
}
res.status(201).json(redactAgentRowForResponse(createdAgent));
return;
}
const onboardingFirstAgentBundle = await resolveOnboardingFirstAgentBundle({
onboardingFirstAgent: createOnboardingFirstAgent,
actorType: req.actor.type,
@@ -5301,7 +5365,7 @@ export function agentRoutes(
// one since disabled) stays allowed, so a disabled harness does not make an
// existing agent uneditable.
if (runner !== undefined) {
await assertCanUpdateAgent(req, existing);
await assertCanConfigureAgentSetup(req, existing);
const config = hasOwn(patchData, "adapterConfig") ? { ...(replaceAdapterConfig ? {} : existing.adapterConfig), ...asRecord(patchData.adapterConfig) } : existing.adapterConfig;
Object.assign(patchData, await resolveNewAgentRunnerForCompany(db, existing.companyId, {
adapterType: typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType,
@@ -5405,12 +5469,12 @@ export function agentRoutes(
&& (["openai_dot", "muse"].includes(String(rawEffectiveAdapterConfig.provider)) || ["openai_dot", "muse"].includes(String(existingRunnerProvider)))) {
await assertSelectableAdapterType(requestedAdapterType, rawEffectiveAdapterConfig);
}
if (
if (!(existing.lifecycleState === "paused" && existing.pauseReason === "setup") && (
changingAdapterType ||
(requestedAdapterType === "paperclip_runner" &&
(requestedAdapterConfig !== null ||
rawEffectiveAdapterConfig.provider !== existingRunnerProvider))
) {
)) {
await assertFreshPaperclipRunnerProvider(
existing.companyId,
requestedAdapterType,
@@ -5437,24 +5501,38 @@ export function agentRoutes(
adapterConfig: patchData.adapterConfig,
});
}
if (existing.runtimeConfig.aiConnection && requestedRuntimeConfig && !requestedRuntimeConfig.aiConnection) requestedRuntimeConfig.aiConnection = existing.runtimeConfig.aiConnection;
const nextAiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record<string, unknown>;
if (existing.runtimeConfig.aiConnection && requestedRuntimeConfig && !requestedRuntimeConfig.aiConnection
&& !(existing.lifecycleState === "paused" && existing.pauseReason === "setup")) {
const existingBinding = aiRuntimeConnectionBindingSchema.safeParse(existing.runtimeConfig.aiConnection).data;
if (existingBinding && agentHarnessType(existing.adapterType, existing.adapterConfig) === agentHarnessType(requestedAdapterType, nextAiConfig)
&& (existingBinding.mode === "router" || isAiConnectionCompatible(existingBinding,
requestedAdapterType, nextAiConfig.model, nextAiConfig.provider, nextAiConfig.acpxAgent))) {
requestedRuntimeConfig.aiConnection = existingBinding;
}
}
if (!requestedRuntimeConfig?.aiConnection && !existing.runtimeConfig.aiConnection
&& !(existing.lifecycleState === "paused" && existing.pauseReason === "setup")
&& requestedAdapterType === "paperclip_runner" && nextAiConfig.provider === "acpx"
&& nextAiConfig.acpxAgent === "copilot"
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"))) {
requestedRuntimeConfig = { ...(requestedRuntimeConfig ?? existing.runtimeConfig),
aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } };
}
const nextAiBinding = aiRuntimeConnectionBindingSchema.safeParse(requestedRuntimeConfig?.aiConnection ?? existing.runtimeConfig.aiConnection).data;
const nextAiBinding = aiRuntimeConnectionBindingSchema.safeParse(requestedRuntimeConfig ? requestedRuntimeConfig.aiConnection : existing.runtimeConfig.aiConnection).data;
if (nextAiBinding) {
await assertCanUpdateAgent(req, existing);
await assertCanConfigureAgentSetup(req, existing);
const changed = JSON.stringify(nextAiBinding) !== JSON.stringify(existing.runtimeConfig.aiConnection);
const aiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record<string, unknown>;
if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot"
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"));
if (changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true);
if (changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) {
const configuringSetup = existing.lifecycleState === "paused" && existing.pauseReason === "setup";
await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding,
(patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null,
!configuringSetup, configuringSetup);
}
}
if (requestedRuntimeConfig) patchData.runtimeConfig = requestedRuntimeConfig;
if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) {
@@ -5476,7 +5554,7 @@ export function agentRoutes(
if (profileOnlyChange) {
await assertCanApplyAgentProfileChange(req, existing);
} else {
await assertCanUpdateAgent(req, existing);
await assertCanConfigureAgentSetup(req, existing);
}
const actor = getActorInfo(req);
+3 -3
View File
@@ -34,7 +34,7 @@ export async function prepareAgentHire(db: Db, companyId: string, input: CreateA
.select({ id: agents.id, name: agents.name, status: agents.status })
.from(agents)
.where(eq(agents.companyId, companyId));
const uniqueName = deduplicateAgentName(data.name, existingAgents);
const uniqueName = deduplicateAgentName(data.name, existingAgents.filter(agent => agent.id !== options?.setupAgentId));
const role = data.role ?? "general";
const normalizedPermissions = normalizeAgentPermissions(data.permissions, { context: "create" });
@@ -60,7 +60,7 @@ export async function initializeAgentHire(txDb: Db, created: AgentHireRecord, op
await agentIdentityService(txDb).ensureAgentIdentity(companyId, created.id);
// New standard agents receive the standard direct grants at activation.
// Low-trust and bundled agents keep their explicit, narrower grants.
if (created.status !== "pending_approval" && !permissionsImplyLowTrust(created.permissions) &&
if (!options?.setupOnly && created.status !== "pending_approval" && !permissionsImplyLowTrust(created.permissions) &&
!readBuiltInAgentMarker(created.metadata)) {
await tx.insert(principalPermissionGrants).values(
NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
@@ -82,7 +82,7 @@ export async function initializeAgentHire(txDb: Db, created: AgentHireRecord, op
}))).onConflictDoNothing();
}
await agentCredentialService(txDb).syncAgentSecretBindings(created, txDb);
if (options?.createdByUserId && !readBuiltInAgentMarker(created.metadata)) {
if (!options?.setupOnly && options?.createdByUserId && !readBuiltInAgentMarker(created.metadata)) {
await initializePrimaryAgent(txDb, companyId, options.createdByUserId, created.id);
}
}
+3
View File
@@ -117,6 +117,9 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
) => {
const existing = await getById(id);
if (!existing) throw notFound("Agent not found");
if (existing.lifecycleHolds?.includes("setup")) {
throw conflict("Finish agent setup before creating keys");
}
if (existing.status === "pending_approval") {
throw conflict("Cannot create keys for pending approval agents");
}
+107 -2
View File
@@ -114,6 +114,39 @@ test.describe("Onboarding wizard", () => {
"onboarding must not invent a mission the customer never gave",
).toBeFalsy();
// Name submission persists an inactive record before the provider question.
await page.locator("#onboarding-agent-name").fill("Ada");
const firstSetupResponse = page.waitForResponse(response =>
response.request().method() === "POST" && response.url().endsWith(`/api/companies/${company.id}/agents`));
await page.getByRole("button", { name: "Next", exact: true }).click();
const firstSetup = await (await firstSetupResponse).json();
expect(firstSetup).toMatchObject({ name: "Ada", pauseReason: "setup", metadata: { setupAdapterPending: true } });
await expect(page.getByRole("radio").first()).toBeVisible();
await expect(page.locator('[role="radio"][aria-checked="true"]')).toHaveCount(0);
// The roster path saves on Choose adapter, then configures that same row.
await page.goto(`/${company.issuePrefix}/agents/all`);
await page.getByRole("toolbar", { name: "Agents controls" }).getByRole("button", { name: "New Agent", exact: true }).click();
await page.getByLabel("Agent name", { exact: true }).fill("Atlas");
const nextSetupResponse = page.waitForResponse(response =>
response.request().method() === "POST" && response.url().endsWith(`/api/companies/${company.id}/agents`));
await page.getByRole("button", { name: "Choose adapter", exact: true }).click();
const nextSetup = await (await nextSetupResponse).json();
expect(nextSetup).toMatchObject({ name: "Atlas", pauseReason: "setup", metadata: { setupAdapterPending: true } });
await expect(page.getByRole("heading", { name: "Choose an adapter", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("main").getByRole("link", { name: /^Atlas / }).click();
await page.getByRole("button", { name: "Finish setup", exact: true }).click();
await expect(page.getByRole("heading", { name: "Choose an adapter", exact: true })).toBeVisible();
await page.locator("label").filter({ has: page.locator('input[name="new-agent-adapter"][value="codex_local"]') }).click();
await page.getByRole("button", { name: "Configure agent", exact: true }).click();
await expect(page.getByRole("heading", { name: "Connect a model", exact: true })).toBeVisible();
expect(new URL(page.url()).searchParams.get("setupAgentId")).toBe(nextSetup.id);
const saved = await (await page.request.get(`/api/agents/${nextSetup.id}?companyId=${company.id}`)).json();
expect(saved).toMatchObject({ id: nextSetup.id, name: "Atlas", pauseReason: "setup", metadata: { setupAdapterPending: false } });
const records = await (await page.request.get(`/api/companies/${company.id}/agents`)).json();
expect(records.map((agent: { id: string }) => agent.id).sort()).toEqual([firstSetup.id, nextSetup.id].sort());
// The expanded wizard must not crash the app (Rules-of-Hooks regression).
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
});
@@ -388,6 +421,16 @@ test.describe("Onboarding wizard", () => {
await route.fulfill({ response });
});
// The synthetic sandbox only drives the login UI; persist configuration
// against the real local default so a failed fixture write cannot mask restore behavior.
await page.route("**/api/agents/*", async (route) => {
if (route.request().method() !== "PATCH") return route.continue();
const body = route.request().postDataJSON();
if (body.defaultEnvironmentId !== FAKE_SANDBOX_ENVIRONMENT_ID) return route.continue();
const response = await route.fetch({ postData: JSON.stringify({ ...body, defaultEnvironmentId: null }) });
await route.fulfill({ response });
});
const SESSION_ID = "e2e-reload-setup-token-session";
const AUTHORIZATION_URL = "https://claude.ai/oauth/authorize?code=true&client=e2e-reload";
let startCalls = 0;
@@ -523,14 +566,22 @@ test.describe("Onboarding wizard", () => {
};
const readDraft = () => page.evaluate(() => JSON.parse(localStorage.getItem("paperclip-onboarding-state") ?? "null"));
await expect.poll(readDraft).toMatchObject(savedSelection);
const { setupAgentId } = await readDraft();
await expect.poll(async () => (await (await page.request.get(`/api/agents/${setupAgentId}?companyId=${company.id}`)).json()).metadata?.setupAdapterPending).toBe(false);
const setupResponse = await page.request.get(`/api/agents/${setupAgentId}?companyId=${company.id}`);
expect(setupResponse.ok()).toBe(true);
const setup = await setupResponse.json();
expect(setup).toMatchObject({ id: setupAgentId, name: "Ada", pauseReason: "setup" });
const resumedSelection = { ...savedSelection, setupAgentId,
runner: setup.adapterType === "paperclip_runner" ? "paperclip" : "legacy" };
const assertResumedLogin = async () => {
await expect(page.getByRole("heading", { name: "Connect a model", exact: true })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("Ada", { exact: true })).toBeVisible();
await expect(page.getByTestId("onboarding-wizard").getByText("Ada", { exact: true })).toBeVisible();
await expect(page.getByRole("radio", { name: "Claude Subscription", exact: true })).toBeChecked();
await expect(cardInstruction).toBeVisible({ timeout: 15_000 });
await expect(authorizationLink).toBeVisible({ timeout: 15_000 });
await expect(authorizationLink).toHaveAttribute("href", AUTHORIZATION_URL);
await expect.poll(readDraft).toMatchObject(savedSelection);
await expect.poll(readDraft).toMatchObject(resumedSelection);
expect(startCalls, "reopening must resume the session without a second source choice").toBe(1);
expect(hireCalls, "a pending login must not hire an agent").toBe(0);
expect(loginSessionReads.length).toBeGreaterThan(0);
@@ -628,4 +679,58 @@ test.describe("Onboarding wizard", () => {
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
});
test("name-only setup saves the advanced Grok ACPX adapter on the same record", async ({ page }) => {
const previous = await (await page.request.get("/api/instance/settings/experimental")).json();
await page.request.patch("/api/instance/settings/experimental", { data: { enableNativeRunner: true } });
try {
const response = await page.request.post("/api/companies", { data: { name: `${COMPANY_NAME}-grok-setup` } });
expect(response.ok()).toBe(true);
const company = await response.json();
await page.goto(`/${company.issuePrefix}/agents/all`);
await page.getByRole("toolbar", { name: "Agents controls" }).getByRole("button", { name: "New Agent", exact: true }).click();
await page.getByLabel("Agent name", { exact: true }).fill("Galen");
const setupResponse = page.waitForResponse(result => result.request().method() === "POST"
&& result.url().endsWith(`/api/companies/${company.id}/agents`));
await page.getByRole("button", { name: "Choose adapter", exact: true }).click();
const setup = await (await setupResponse).json();
await page.locator("summary").filter({ hasText: "Advanced" }).click();
await page.getByRole("button", { name: "Experimental harness", exact: true }).click();
await page.getByRole("option", { name: "Grok Build (Paperclip Runner)", exact: true }).click();
await page.getByRole("button", { name: "Configure agent", exact: true }).click();
await expect(page.getByRole("heading", { name: "Connect a model", exact: true })).toBeVisible();
expect(new URL(page.url()).searchParams.get("setupAgentId")).toBe(setup.id);
const saved = await (await page.request.get(`/api/agents/${setup.id}?companyId=${company.id}`)).json();
expect(saved).toMatchObject({ id: setup.id, adapterType: "paperclip_runner", pauseReason: "setup",
adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "grok-4.7" }, metadata: { setupAdapterPending: false } });
} finally {
await page.request.patch("/api/instance/settings/experimental", { data: { enableNativeRunner: previous.enableNativeRunner === true } });
}
});
test("duplicate name drafts can select Copilot before connecting a GitHub account", async ({ page }) => {
const response = await page.request.post("/api/companies", { data: { name: `${COMPANY_NAME}-duplicate-copilot` } });
expect(response.ok()).toBe(true);
const company = await response.json();
await page.goto(`/${company.issuePrefix}/agents/all`);
let duplicate: { id: string; name: string };
for (let attempt = 0; attempt < 2; attempt++) {
await page.getByRole("toolbar", { name: "Agents controls" }).getByRole("button", { name: "New Agent", exact: true }).click();
await page.getByLabel("Agent name", { exact: true }).fill("Echo");
const setupResponse = page.waitForResponse(result => result.request().method() === "POST" && result.url().endsWith(`/api/companies/${company.id}/agents`));
await page.getByRole("button", { name: "Choose adapter", exact: true }).click();
duplicate = await (await setupResponse).json();
await expect(page.getByRole("heading", { name: "Choose an adapter", exact: true })).toBeVisible();
if (attempt === 0) await page.keyboard.press("Escape");
}
expect(duplicate!.name).toBe("Echo 2");
await expect(page.getByText("How should Echo 2 work?", { exact: true })).toBeVisible();
await page.locator("label").filter({ has: page.locator('input[name="new-agent-adapter"][value="copilot_runtime"]') }).click();
await page.getByRole("button", { name: "Configure agent", exact: true }).click();
await expect(page.getByRole("heading", { name: "Configure your agent", exact: true })).toBeVisible();
expect(new URL(page.url()).searchParams.get("setupAgentId")).toBe(duplicate!.id);
const saved = await (await page.request.get(`/api/agents/${duplicate!.id}?companyId=${company.id}`)).json();
expect(saved).toMatchObject({ id: duplicate!.id, name: "Echo 2", pauseReason: "setup", adapterConfig: { provider: "acpx", acpxAgent: "copilot" } });
expect(saved.runtimeConfig.aiConnection).toBeUndefined();
});
});
@@ -134,6 +134,18 @@ describe("AgentActionButtons", () => {
);
}
it.each(["codex_local", "claude_local", "paperclip_runner"])("returns unfinished %s agents to their saved runner", async adapterType => {
render(makeAgent({ adapterType, status: "paused", lifecycleState: "paused", pauseReason: "setup" }));
await flushReact();
const finish = [...container.querySelectorAll("button")].find(button => button.textContent === "Finish setup");
expect(finish).toBeTruthy();
expect([...container.querySelectorAll("button")].some(button => button.textContent === "Resume")).toBe(false);
await act(() => finish!.click());
expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining("setupAgentId=agent-1"));
expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining(`runner=${adapterType === "paperclip_runner" ? "paperclip" : "legacy"}`));
expect(mockAgentsApi.resume).not.toHaveBeenCalled();
});
it.each(["preparing", "verifying", "resuming"] as const)("offers pause rather than resume during %s", async lifecycleState => {
render(makeAgent({ status: "paused", lifecycleState }));
await flushReact();
+6
View File
@@ -433,6 +433,12 @@ export function AgentActionButtons({
disabled={clearErrorDisabled}
size={size}
/>
) : agent.pauseReason === "setup" && agent.lifecycleState === "paused" ? (
<Button variant="outline" size={size} disabled={disabled} onClick={() => navigate(`/agents/new?${new URLSearchParams({
name: agent.name, adapterType: agent.adapterType, setupAgentId: agent.id,
runnerProvider: String(agent.adapterConfig.provider === "acpx" ? agent.adapterConfig.acpxAgent : agent.adapterConfig.provider ?? "codex"),
...(agent.adapterType === "paperclip_runner" ? { runner: "paperclip" } : ["codex_local", "claude_local"].includes(agent.adapterType) ? { runner: "legacy" } : {}),
})}`)}>Finish setup</Button>
) : (
<PauseResumeButton
isPaused={isPaused}
+116 -2
View File
@@ -10,6 +10,8 @@ vi.mock("@/api/instanceSettings", () => ({
getExperimental: async () => ({ enableNativeRunner: true }),
},
}));
const agents = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn(), get: vi.fn(), list: vi.fn() }));
vi.mock("@/api/agents", () => ({ agentsApi: agents }));
const invites = vi.hoisted(() => ({ createCompanyInvite: vi.fn(), getInviteOnboarding: vi.fn(), copy: vi.fn() }));
vi.mock("../api/access", () => ({ accessApi: invites }));
vi.mock("../lib/clipboard", () => ({ copyTextToClipboard: invites.copy }));
@@ -42,6 +44,20 @@ async function click(label: string) {
}
beforeEach(async () => {
vi.clearAllMocks();
const rows = new Map();
Object.values(agents).forEach(mock => mock.mockReset());
agents.list.mockResolvedValue([]);
agents.create.mockImplementation(async (_company, input) => {
const record = { ...input, id: input.setupId, pauseReason: "setup" };
rows.set(record.id, record);
return record;
});
agents.get.mockImplementation(async id => rows.get(id));
agents.update.mockImplementation(async (id, input) => {
const record = { ...rows.get(id), ...input, id };
rows.set(id, record);
return record;
});
Object.values(dotApi).forEach(mock => mock.mockReset());
invites.createCompanyInvite.mockResolvedValue({ token: "one-time-token", onboardingTextPath: "/api/invites/one-time-token/onboarding.txt" });
invites.getInviteOnboarding.mockResolvedValue({ onboarding: { connectivity: {} } });
@@ -71,13 +87,13 @@ afterEach(async () => {
cache.clear();
container.remove();
});
async function name() {
async function name(value = "Ada & Co") {
const input = document.querySelector("input")!;
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!.call(input, "Ada & Co");
)!.set!.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await click("Choose adapter");
@@ -101,6 +117,101 @@ it("requires a name and an enabled adapter before navigating", async () => {
expect(query.get("name")).toBe("Ada & Co");
expect(query.get("adapterType")).toBe("codex_local");
});
it("saves identity on name submission before an adapter is selected", async () => {
expect(agents.create).not.toHaveBeenCalled();
await name();
expect(agents.create).toHaveBeenCalledTimes(1);
const input = agents.create.mock.calls[0][1];
expect(input).toMatchObject({ name: "Ada & Co", adapterType: "process", metadata: { setupAdapterPending: true }, runtimeConfig: { heartbeat: { enabled: false } } });
expect(document.body.textContent).toContain("Choose an adapter");
expect(document.querySelector('input[name="new-agent-adapter"]:checked')).toBeNull();
expect(state.navigate).not.toHaveBeenCalled();
await act(async () => document.querySelector<HTMLInputElement>('input[value="codex_local"]')!.click());
await click("Configure agent");
expect(agents.create).toHaveBeenCalledTimes(1);
expect(agents.update).toHaveBeenCalledWith(input.setupId, expect.objectContaining({ adapterType: "codex_local", metadata: { setupAdapterPending: false } }), "company-1");
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
expect(query.get("setupAgentId")).toBe(input.setupId);
expect(query.get("runner")).toBe("auto");
});
it("retries a failed name save with the same identity", async () => {
agents.create.mockRejectedValueOnce(new Error("Creation unavailable"));
await name();
expect(document.body.textContent).toContain("Creation unavailable");
expect(document.body.textContent).not.toContain("Choose an adapter");
await click("Choose adapter");
expect(agents.create).toHaveBeenCalledTimes(2);
expect(agents.create.mock.calls[1][1].setupId).toBe(agents.create.mock.calls[0][1].setupId);
expect(document.body.textContent).toContain("Choose an adapter");
});
it("keeps the draft on adapter save failure and updates it on retry", async () => {
await name();
await act(async () => document.querySelector<HTMLInputElement>('input[value="codex_local"]')!.click());
agents.update.mockRejectedValueOnce(new Error("Save unavailable"));
await click("Configure agent");
expect(document.body.textContent).toContain("Save unavailable");
expect(state.navigate).not.toHaveBeenCalled();
await click("Configure agent");
expect(agents.update.mock.calls[1][0]).toBe(agents.create.mock.calls[0][1].setupId);
expect(state.navigate).toHaveBeenCalledTimes(1);
});
it("keeps the same record after returning to the name step", async () => {
await name();
await click("Back");
await name();
expect(agents.create).toHaveBeenCalledTimes(1);
});
it("ignores repeated name submission while the record is being saved", async () => {
let finish!: (value: object) => void;
agents.create.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
await name();
const input = agents.create.mock.calls[0][1];
await act(async () => {
const form = document.querySelector("form")!;
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
expect(agents.create).toHaveBeenCalledTimes(1);
await act(async () => finish({ ...input, id: input.setupId }));
expect(document.body.textContent).toContain("Choose an adapter");
});
it("does not navigate after closing while an adapter save is pending", async () => {
await name();
await act(async () => document.querySelector<HTMLInputElement>('input[value="codex_local"]')!.click());
let finish!: (value: object) => void;
agents.update.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
await click("Configure agent");
await act(async () => root.render(null));
await act(async () => finish({ id: agents.create.mock.calls[0][1].setupId, name: "Ada & Co" }));
expect(state.navigate).not.toHaveBeenCalled();
});
it("can save the name when the process adapter is disabled", async () => {
await act(async () => cache.setQueryData(queryKeys.adapters.all, [...state.adapters, { type: "process", loaded: true, disabled: true }]));
await name();
expect(agents.create.mock.calls[0][1]).toMatchObject({ adapterType: "codex_local", metadata: { setupAdapterPending: true } });
expect(document.querySelector('input[name="new-agent-adapter"]:checked')).toBeNull();
});
it("keeps the server-assigned name when a duplicate is numbered", async () => {
agents.create.mockImplementationOnce(async (_company, input) => ({ ...input, id: input.setupId, name: "Ada & Co 2" }));
await name();
expect(document.body.textContent).toContain("How should Ada & Co 2 work?");
expect(agents.update).not.toHaveBeenCalled();
await act(async () => document.querySelector<HTMLInputElement>('input[value="codex_local"]')!.click());
await click("Configure agent");
expect(agents.update.mock.calls[0][1].name).toBe("Ada & Co 2");
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
expect(query.get("name")).toBe("Ada & Co 2");
});
it("renames a numbered draft only after the user edits its name", async () => {
agents.create.mockImplementationOnce(async (_company, input) => ({ ...input, id: input.setupId, name: "Ada & Co 2" }));
await name();
expect(agents.update).not.toHaveBeenCalled();
await click("Back");
await name("Grace");
expect(agents.create).toHaveBeenCalledTimes(1);
expect(agents.update).toHaveBeenCalledWith(agents.create.mock.calls[0][1].setupId, { name: "Grace" }, "company-1");
expect(document.body.textContent).toContain("How should Grace work?");
});
it("offers one Codex harness without a runner/provider tile", async () => {
await name();
expect(document.querySelector('input[value="paperclip_runner"]')).toBeNull();
@@ -130,6 +241,9 @@ it.each(["grok", "opencode"])("preserves explicit experimental %s creation in Ad
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
expect(query.get("adapterType")).toBe("paperclip_runner");
expect(query.get("runnerProvider")).toBe(provider);
expect(agents.update).toHaveBeenCalledWith(agents.create.mock.calls[0][1].setupId, expect.objectContaining({
adapterType: "paperclip_runner", adapterConfig: provider === "grok" ? { provider: "acpx", acpxAgent: "grok", model: "grok-4.7" } : { provider: "opencode" },
}), "company-1");
});
it.each(["gate off", "disabled adapter"])("hides experimental native creation when %s", async reason => {
await act(async () => {
@@ -32,6 +32,7 @@ const mockGoalsApi = vi.hoisted(() => ({
const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockAgentsApi = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
adapterModels: vi.fn(),
list: vi.fn(),
hire: vi.fn(),
@@ -186,6 +187,10 @@ describe("OnboardingWizard — which step it lands on", () => {
// The hire step lists the company's agents first so it can adopt one that
// already carries the typed name instead of hiring a duplicate.
mockAgentsApi.list.mockResolvedValue([]);
mockAgentsApi.create.mockImplementation(async (companyId, input) => ({
...input, id: input.setupId, companyId, status: "paused", pauseReason: "setup",
}));
mockAgentsApi.update.mockResolvedValue({});
mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null });
mockAgentsApi.instructionsBundle.mockResolvedValue({ entryFile: "AGENTS.md" });
mockAgentsApi.saveInstructionsFile.mockResolvedValue({});
@@ -596,6 +601,7 @@ describe("OnboardingWizard — which step it lands on", () => {
const [, payload] = mockAgentsApi.hire.mock.calls.at(-1)!;
expect(payload.role).toBe("general");
expect(payload.name).toBe("Ada");
expect(payload.setupAgentId).toBe(mockAgentsApi.create.mock.calls[0]?.[1].setupId);
});
it("does not offer a way back behind the step it entered on", async () => {
+246 -7
View File
@@ -79,6 +79,8 @@ const mockGoalsApi = vi.hoisted(() => ({
list: vi.fn(async () => []),
}));
const mockAgentsApi = vi.hoisted(() => ({
create: vi.fn(async (_company: string, input: Record<string, unknown>) => ({ ...input, id: input.setupId, pauseReason: "setup" })),
update: vi.fn(async (_id: string, input: Record<string, unknown>) => input),
adapterModels: vi.fn(async () => [] as Array<{ id: string; label: string }>),
testEnvironment: vi.fn(
async (): Promise<import("@paperclipai/shared").AdapterEnvironmentTestResult> => ({
@@ -420,6 +422,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
testedAt: new Date().toISOString(),
});
mockAgentsApi.hire.mockReset();
mockAgentsApi.list.mockReset().mockResolvedValue([]);
mockAgentsApi.create.mockReset().mockImplementation(async (_company, input) => ({ ...input, id: input.setupId, pauseReason: "setup" }));
mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null });
mockCompaniesApi.create.mockResolvedValue({
id: "created",
@@ -441,15 +445,16 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
describe("qualified Copilot onboarding", () => {
async function openCopilot() {
async function openCopilot(overrides: Record<string, unknown> = {}) {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false, enableNativeRunner: true });
mockCompany.companies = [{ id: "c1", name: "Canary", issuePrefix: "CAN" }];
mockCompaniesApi.list.mockResolvedValue(mockCompany.companies);
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "paperclip_runner" }];
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 4, createdCompanyId: "c1", agentName: "Copilot canary" }));
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 4, createdCompanyId: "c1", agentName: "Copilot canary", ...overrides }));
mockAgentsApi.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "GPT 5.6 Luna" }]);
mockAdapterBuild.buildAdapterConfig.mockImplementation((...args: unknown[]) => {
const values = args[0] as { adapterSchemaValues: Record<string, unknown>; model: string };
if (!values.model) throw new Error("Copilot requires a selected model");
return { ...values.adapterSchemaValues, model: values.model };
});
const { root, queryClient } = render();
@@ -467,6 +472,34 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
return { root, click };
}
it("keeps Copilot selection available while its setup has no model", async () => {
const { root, click } = await openCopilot();
try {
expect(mockAgentsApi.create).toHaveBeenCalled();
expect(mockAgentsApi.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot" }, metadata: { setupAdapterPending: false } }), "c1");
await click("Use saved Copilot connection");
await vi.waitFor(() => expect(document.body.querySelector('[aria-label="Copilot model"]')?.textContent).toContain("GPT 5.6 Luna"));
const model = document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement;
await act(async () => { model.value = "gpt-5.6-luna"; model.dispatchEvent(new Event("change", { bubbles: true })); });
await vi.waitFor(() => expect(mockAgentsApi.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterConfig: expect.objectContaining({ model: "gpt-5.6-luna" }) }), "c1"));
} finally { await act(async () => root.unmount()); }
});
it("restores the saved Copilot source and model without switching providers", async () => {
const setupId = "00000000-0000-4000-8000-000000000085";
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "c1", name: "Copilot canary", role: "general",
adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
pauseReason: "setup", runtimeConfig: { aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } },
} as never);
const { root, click } = await openCopilot({ adapterType: "paperclip_runner", onboardingCopilot: true, setupAgentId: setupId });
try {
await vi.waitFor(() => expect((document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement)?.value).toBe("gpt-5.6-luna"));
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ adapterType: "paperclip_runner", model: "gpt-5.6-luna" });
await click("Connect");
expect(mockAgentsApi.hire).toHaveBeenCalledWith("c1", expect.objectContaining({ setupAgentId: setupId, adapterType: "paperclip_runner" }));
} finally { await act(async () => root.unmount()); }
});
it.each(["choices", "authentication error"])("refreshes Copilot onboarding after reconnecting the same binding with cached %s", async (cachedState) => {
const { root, click } = await openCopilot();
try {
@@ -513,16 +546,16 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
it("reuses an existing Copilot hire after losing its draft id without adopting another Runner", async () => {
mockAgentsApi.list.mockResolvedValue([
{ id: "codex-other", name: "Copilot canary", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } },
{ id: "copilot-existing", name: "Copilot canary", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" } },
]);
const { root, click } = await openCopilot();
try {
await click("Use saved Copilot connection");
const select = document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement;
await act(async () => { select.value = "gpt-5.6-luna"; select.dispatchEvent(new Event("change", { bubbles: true })); });
await flushReact();
mockAgentsApi.list.mockResolvedValue([
{ id: "codex-other", name: "Copilot canary", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } },
{ id: "copilot-existing", name: "Copilot canary", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" } },
]);
await click("Connect");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ createdAgentId: "copilot-existing" });
@@ -645,7 +678,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockCompany.loading = false;
mockCompaniesApi.list.mockResolvedValue([]);
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
mockAgentsApi.list.mockResolvedValueOnce([
mockAgentsApi.list.mockResolvedValue([
{ id: "agent-existing", name: "Ada", adapterType: "claude_local" },
]);
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
@@ -2458,6 +2491,212 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
for (let i = 0; i < 8; i++) await flushReact();
}
it("saves the submitted name before any provider choice or authentication", async () => {
const { root } = await openStep4();
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.create.mock.calls[0][1]).toMatchObject({ name: "Ada", adapterType: "process", metadata: { setupAdapterPending: true }, runtimeConfig: { heartbeat: { enabled: false } } });
expect(document.body.querySelector('[role="radio"][aria-checked="true"]')).toBeNull();
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
expect(managedApi.startLocalLogin).not.toHaveBeenCalled();
await pickSource(/OpenAI/);
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterType: "codex_local", metadata: { setupAdapterPending: false } }), "company-new");
await act(async () => root.unmount());
});
it("saves a provisional enabled provider when process is disabled without choosing its tile", async () => {
mockAdapterRegistry.disabled = new Set(["process"]);
const { root } = await openStep4();
expect(mockAgentsApi.create.mock.calls[0][1]).toMatchObject({ adapterType: "claude_local", metadata: { setupAdapterPending: true } });
expect(document.body.querySelector('[role="radio"][aria-checked="true"]')).toBeNull();
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it("creates its own record instead of adopting another user's name-only setup", async () => {
mockAgentsApi.list.mockResolvedValue([{ id: "another-user-setup", companyId: "company-new", name: "Ada", pauseReason: "setup", createdByUserId: "other-user", adapterType: "process", adapterConfig: {}, metadata: { setupAdapterPending: true } } as never]);
mockAgentsApi.create.mockImplementationOnce(async (_company, input) => ({ ...input, id: input.setupId, name: "Ada 2", pauseReason: "setup" }));
const { root } = await openStep4();
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
const setupId = mockAgentsApi.create.mock.calls[0][1].setupId;
expect(setupId).not.toBe("another-user-setup");
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ agentName: "Ada 2", setupAgentId: setupId });
await pickSource(/OpenAI/);
expect(mockAgentsApi.update.mock.calls.every(([id]) => id === setupId)).toBe(true);
expect(mockAgentsApi.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ name: "Ada 2" }), "company-new");
await act(async () => root.unmount());
});
it("restores a name-only record without restoring its provisional runner or model", async () => {
const setupId = "00000000-0000-4000-8000-000000000094";
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general", adapterType: "process", adapterConfig: { model: "provisional-model" }, runtimeConfig: {}, pauseReason: "setup", metadata: { setupAdapterPending: true } } as never);
const { root } = await openStep4({ adapterType: "process", runner: "legacy", model: "stale-model", setupAgentId: setupId });
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ runner: "auto", model: "", setupAgentId: setupId });
expect(document.body.querySelector('[role="radio"][aria-checked="true"]')).toBeNull();
await pickSource(/Claude/);
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).runner).toBe("auto");
expect(mockAgentsApi.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ runner: "auto" }), "company-new");
await act(async () => root.unmount());
});
it("creates and saves an inactive identity before starting sandbox sign-in", async () => {
let finish!: (agent: { id: string; pauseReason: string }) => void;
mockAgentsApi.create.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }) as never);
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
const input = mockAgentsApi.create.mock.calls[0][1];
expect(input).toMatchObject({ name: "Ada", runtimeConfig: { heartbeat: { enabled: false } } });
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ setupAgentId: input.setupId });
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => finish({ id: String(input.setupId), pauseReason: "setup" }));
for (let i = 0; i < 10; i++) await flushReact();
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
it("reuses the persisted setup identity on a fresh onboarding mount", async () => {
const setupId = "00000000-0000-4000-8000-000000000081";
const { root } = await openStep4({ adapterType: "claude_local", setupAgentId: setupId });
await pickSource(/Claude/);
expect(mockAgentsApi.create).toHaveBeenCalledWith("company-new", expect.objectContaining({ setupId }));
await act(async () => root.unmount());
});
it("saves chosen model settings on the setup record before hiring", async () => {
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => ({ model: values?.model }));
const { root } = await openStep4({ adapterType: "codex_local", model: "gpt-5.4" });
await pickSource(/OpenAI/);
const setupId = mockAgentsApi.create.mock.calls[0][1].setupId;
expect(mockAgentsApi.update).toHaveBeenCalledWith(setupId, expect.objectContaining({
adapterConfig: { model: "gpt-5.4" }, runtimeConfig: expect.objectContaining({ heartbeat: expect.objectContaining({ enabled: false }) }),
}), "company-new");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it("loads the server setup before an older local draft can autosave over it", async () => {
const setupId = "00000000-0000-4000-8000-000000000082";
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => ({ model: values?.model }));
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general",
adapterType: "codex_local", adapterConfig: { model: "server-model", customSetting: "preserve" },
runtimeConfig: {}, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never);
const { root } = await openStep4({ adapterType: "codex_local", model: "old-local-model", setupAgentId: setupId });
await pickSource(/OpenAI/);
const saves = mockAgentsApi.update.mock.calls.filter(call => call[0] === setupId);
expect(saves.length).toBeGreaterThan(0);
for (const [, body] of saves.filter(([, body]) => body.adapterConfig)) expect(body.adapterConfig).toMatchObject({ model: "server-model", customSetting: "preserve" });
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).model).toBe("server-model");
await act(async () => root.unmount());
});
it("tests and hires in the restored setup environment instead of the instance default", async () => {
const setupId = "00000000-0000-4000-8000-000000000083";
mockEnvironmentsApi.list.mockResolvedValue([SANDBOX_ENVIRONMENT, { ...SANDBOX_ENVIRONMENT, id: "saved-environment" }]);
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-claude", latestVersion: 1 });
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general",
adapterType: "claude_local", adapterConfig: { model: "server-model" }, runtimeConfig: {},
pauseReason: "setup", defaultEnvironmentId: "saved-environment" } as never);
const { root } = await openStep4({ adapterType: "claude_local", setupAgentId: setupId });
await pickSource(/Claude/);
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "claude_local", expect.objectContaining({ environmentId: "saved-environment" }));
expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ defaultEnvironmentId: "saved-environment" }));
await act(async () => root.unmount());
});
it("drops the restored OpenAI connection after switching to a stored Claude login", async () => {
mockAgentsApi.testEnvironment.mockImplementation(async (...args: unknown[]) => ({ adapterType: args[1] as string,
status: args[1] === "codex_local" ? "fail" as const : "pass" as const, checks: [], testedAt: new Date().toISOString() }));
const setupId = "00000000-0000-4000-8000-000000000084";
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-claude", latestVersion: 1 });
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general",
adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" }, runtimeConfig: {
aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" }, customSetting: "preserve",
}, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never);
const { root } = await openStep4({ adapterType: "codex_local", setupAgentId: setupId });
await pickSource(/OpenAI/);
const back = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!;
await act(async () => back.click());
for (let i = 0; i < 10; i++) await flushReact();
await pickSource(/Claude/);
const saves = mockAgentsApi.update.mock.calls.filter(([, body]) => body.adapterType === "claude_local");
expect(saves.length).toBeGreaterThan(0);
for (const [, body] of saves) expect(body.runtimeConfig).not.toHaveProperty("aiConnection");
const hire = (mockAgentsApi.hire.mock.calls as unknown as Array<[string, { adapterType: string; runtimeConfig: Record<string, unknown> }]>).find(([, body]) => body.adapterType === "claude_local")?.[1];
expect(hire).toBeTruthy();
expect(hire!.runtimeConfig).toMatchObject({ customSetting: "preserve" });
expect(hire!.runtimeConfig).not.toHaveProperty("aiConnection");
await act(async () => root.unmount());
});
it("tests restored redacted environment values using the setup record", async () => {
const setupId = "00000000-0000-4000-8000-000000000086";
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general",
adapterType: "codex_local", adapterConfig: { env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" }, OPENAI_API_KEY: { type: "secret_ref", secretId: "saved-key" } } },
runtimeConfig: {}, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never);
const { root } = await openStep4({ adapterType: "codex_local", setupAgentId: setupId });
await pickSource(/OpenAI/);
await pressArcPrimary();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "codex_local", expect.objectContaining({ agentId: setupId,
adapterConfig: expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: { type: "plain", value: "***REDACTED***" } }) }),
}));
await act(async () => root.unmount());
});
it.each(["api", "subscription"] as const)("removes old Claude credentials when resuming with %s authentication", async mode => {
let changedMode = false;
mockAgentsApi.testEnvironment.mockImplementation(async () => ({ adapterType: "claude_local",
status: changedMode ? "pass" as const : "fail" as const, checks: [], testedAt: new Date().toISOString() }));
const setupId = "00000000-0000-4000-8000-000000000087";
const secret = { type: "secret_ref", secretId: "saved-key" };
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-claude", latestVersion: 1 });
mockSecretsApi.list.mockResolvedValue([{ id: "saved-key", companyId: "company-new", name: "Saved API key", key: "ANTHROPIC_API_KEY", scope: "company", status: "active" }] as never);
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general",
adapterType: "claude_local", adapterConfig: { env: { KEEP: { type: "plain", value: "***REDACTED***" },
[mode === "api" ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY"]: secret } },
runtimeConfig: {}, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never);
const { root } = await openStep4({ adapterType: "claude_local", setupAgentId: setupId, credentialModeChoice: mode });
await pickSource(/Claude/);
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).credentialMode).toBe(mode === "api" ? "subscription" : "api");
const back = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!;
await act(async () => back.click());
for (let i = 0; i < 10; i++) await flushReact();
changedMode = true;
const switcher = document.body.querySelector(`[aria-label="Use ${mode === "api" ? "API key" : "subscription"} instead"]`) as HTMLButtonElement;
await act(async () => switcher.click());
await pickSource(/Claude/);
if (mode === "api") await pressArcPrimary();
const call = (mockAgentsApi.testEnvironment.mock.calls as unknown[][]).at(-1);
expect(call).toBeTruthy();
const config = (call![2] as { adapterConfig: { env: Record<string, unknown> } }).adapterConfig;
expect(config.env).toHaveProperty("KEEP");
expect(config.env).not.toHaveProperty(mode === "api" ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY");
expect(config.env).toHaveProperty(mode === "api" ? "ANTHROPIC_API_KEY" : "CLAUDE_CODE_OAUTH_TOKEN");
await act(async () => root.unmount());
});
it("starts setup for the new company while the previous company's creation is pending", async () => {
let finish!: (agent: unknown) => void;
mockAgentsApi.create.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }) as never);
const { root, queryClient } = await openStep4({ adapterType: "codex_local" });
await pickSource(/OpenAI/);
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
mockCompany.companies.push({ id: "company-other", name: "Other", issuePrefix: "OTH" });
mockDialog.onboardingOptions = { initialStep: 4, companyId: "company-other" };
await act(async () => root.render(<QueryClientProvider client={queryClient}><OnboardingWizard /></QueryClientProvider>));
for (let i = 0; i < 10; i++) await flushReact();
expect(mockAgentsApi.create).toHaveBeenCalledWith("company-other", expect.objectContaining({ setupId: expect.any(String) }));
const nextId = mockAgentsApi.create.mock.calls.find(call => call[0] === "company-other")![1].setupId;
await act(async () => finish({ id: "old-company-setup", pauseReason: "setup" }));
for (let i = 0; i < 5; i++) await flushReact();
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ createdCompanyId: "company-other", setupAgentId: nextId });
expect(document.body.textContent).not.toContain("Creating your agent…");
await act(async () => root.unmount());
});
it("starts the claude_local sign-in on Connect when the signal reports no ready credential", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
+183 -22
View File
@@ -22,6 +22,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import type {
AdapterEnvironmentTestResult,
Agent,
AgentRole,
ClaudeOAuthTokenStatusResponse,
Environment,
@@ -102,7 +103,7 @@ import { isVisualAdapterChoice } from "../adapters/metadata";
import { useDisabledAdaptersSync, useAdapterRegistryLoaded } from "../adapters/use-disabled-adapters";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { buildFixedClaudeOAuthBinding } from "./environment-variables-editor/model";
import { buildFixedClaudeOAuthBinding, CLAUDE_OAUTH_TOKEN_ENV_KEY } from "./environment-variables-editor/model";
import { defaultCreateValues } from "./agent-config-defaults";
import { restoreOnboardingState } from "../lib/onboarding-state";
import {
@@ -634,6 +635,7 @@ function OnboardingWizardInner({
* whether the row has been *answered* on this visit.
*/
const [sourcePicked, setSourcePicked] = useState(false);
const [setupNameConflict, setSetupNameConflict] = useState<string | null>(null);
const isCopilot = adapterType === "paperclip_runner";
const [copilotConnection, setCopilotConnection] = useState<AiConnectionBinding>();
const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner" && !(saved?.onboardingCopilot === true && copilotOnboardingQualified);
@@ -730,6 +732,13 @@ function OnboardingWizardInner({
string | null
>((saved?.createdCompanyPrefix as string) ?? null);
const [createdAgentId, setCreatedAgentId] = useState<string | null>((saved?.createdAgentId as string) ?? null);
const [setupAgentId, setSetupAgentId] = useState<string | null>((saved?.setupAgentId as string) ?? null);
const [setupPrepared, setSetupPrepared] = useState(false);
const [setupError, setSetupError] = useState(false);
const [setupAttempt, setSetupAttempt] = useState(0);
const preparingSetup = useRef(new Set<string>());
const [restoredSetup, setRestoredSetup] = useState<Agent | null>(null);
const setupSave = useRef<Promise<unknown>>(Promise.resolve());
const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState<string | null>(
(saved?.createdCompanyGoalId as string) ?? null
);
@@ -779,6 +788,16 @@ function OnboardingWizardInner({
const managedSubscriptionProvider = managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? managedProvider : undefined;
function managedBindingForStep(): AiConnectionBinding | undefined {
if (isCopilot) return copilotConnection;
const restoredBinding = restoredSetup?.companyId === createdCompanyId
&& agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType, {})
? restoredSetup.runtimeConfig?.aiConnection as AiConnectionBinding | undefined : undefined;
if (!selectedSavedKey && !apiKey.trim()) {
if (restoredBinding?.method === (credentialMode === "api" ? "api_key" : "subscription")) return restoredBinding;
const savedEnv = restoredSetup?.companyId === createdCompanyId
&& agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType, {})
? restoredSetup.adapterConfig.env as Record<string, unknown> | undefined : undefined;
if (savedEnv?.[credentialMode === "api" ? apiKeyEnvKeyFor(adapterType) : "CODEX_HOME"]) return undefined;
}
if (credentialMode === "api") return selectedApiKey?.aiConnection ?? (
!selectedApiKey && apiKeySecretRef.current?.companyId === createdCompanyId && apiKeySecretRef.current.envKey === apiKeyEnvKeyFor(adapterType)
? apiKeySecretRef.current.aiConnection : undefined);
@@ -811,6 +830,7 @@ function OnboardingWizardInner({
* hand rather than the one before it.
*/
function clearCompanyScopedState() {
setRestoredSetup(null);
setCopilotConnection(undefined);
setAgentAppearance(randomAgentAppearance());
setCreatedCompanyPrefix(null);
@@ -819,6 +839,9 @@ function OnboardingWizardInner({
setCreatedProjectId(null);
setCreatedIssueRef(null);
setCreatedAgentId(null);
setSetupAgentId(null);
setSetupPrepared(false);
setSetupError(false);
}
// Sync step and company when onboarding opens with explicit options.
@@ -929,7 +952,7 @@ function OnboardingWizardInner({
onboardingCopilot: isCopilot,
// The mode, never the key: this blob is localStorage.
credentialMode, credentialModeChoice,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyId, createdCompanyPrefix, createdAgentId, setupAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
};
onboardingDraftStorage.write(JSON.stringify(state));
@@ -937,7 +960,7 @@ function OnboardingWizardInner({
effectiveOnboardingOpen, step, companyName,
agentName, agentAppearance, agentRole, adapterType, runner, cwd, model, command, args, url,
credentialMode, credentialModeChoice,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyId, createdCompanyPrefix, createdAgentId, setupAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
]);
@@ -974,7 +997,7 @@ function OnboardingWizardInner({
const loginEnvironmentResolution = useMemo(() => {
try {
return { environmentId: resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: null,
agentDefaultEnvironmentId: restoredSetup?.companyId === createdCompanyId ? restoredSetup.defaultEnvironmentId : null,
instanceDefaultEnvironmentId: instanceSettingsForLogin?.defaultEnvironmentId ?? null,
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(loginEnvironmentList),
managedSandboxOnly: experimentalSettingsForLogin?.enableManagedSandboxOnly === true,
@@ -985,6 +1008,7 @@ function OnboardingWizardInner({
return { environmentId: null, error: error instanceof Error ? error.message : "Could not resolve the execution environment." };
}
}, [
restoredSetup, createdCompanyId,
instanceSettingsForLogin?.defaultEnvironmentId,
loginEnvironmentList,
experimentalSettingsForLogin?.enableManagedSandboxOnly,
@@ -1109,7 +1133,7 @@ function OnboardingWizardInner({
provider: managedSubscriptionProvider ?? "anthropic", method: "subscription",
name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`,
ownership: "personal", agentIds: [], allAgents: true,
}, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" &&
}, sourcePicked && (setupPrepared || Boolean(createdAgentId)) && effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" &&
Boolean(managedProvider) && !savedSubscription && !savedKeys.storedLogin.data && !managedBindingForStep(),
);
useEffect(() => {
@@ -1263,7 +1287,90 @@ function OnboardingWizardInner({
* Anything that gates this step belongs in here, so the next one is added
* once rather than twice.
*/
const connectStepReady = sourceSelected && !adapterEnvLoading && !savedKeys.loading && runnerDiscoveryReady && (!isCopilot || copilotModelReady);
const restoredApiCredential = restoredSetup?.companyId === createdCompanyId
&& agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType, isCopilot ? { provider: "acpx", acpxAgent: "copilot" } : {})
&& !selectedSavedKey && (restoredSetup.adapterConfig.env as Record<string, unknown> | undefined)?.[apiKeyEnvKeyFor(adapterType)];
const connectStepReady = Boolean(setupPrepared || createdAgentId) && sourceSelected && !adapterEnvLoading && !savedKeys.loading && runnerDiscoveryReady && (!isCopilot || copilotModelReady);
useEffect(() => {
if (step !== 4 || !createdCompanyId || !agentName.trim() || !adapterRegistryLoaded
|| createdAgentId || setupPrepared || preparingSetup.current.has(createdCompanyId) || setupError
|| (!sourceSelected && setupNameConflict === JSON.stringify([createdCompanyId, agentName.trim().toLowerCase()]))) return;
const companyId = createdCompanyId;
preparingSetup.current.add(companyId);
const id = setupAgentId ?? crypto.randomUUID();
let restoring = Boolean(setupAgentId);
void (async () => {
if (!setupAgentId) {
// Only a persisted setup ID identifies an unfinished record for reuse.
const sameName = (await agentsApi.list(companyId)).filter(agent =>
agent.status !== "terminated" && agent.pauseReason !== "setup"
&& agent.name.trim().toLowerCase() === agentName.trim().toLowerCase());
const existing = sourceSelected ? sameName.find(agent => agentHarnessType(agent.adapterType, agent.adapterConfig)
=== agentHarnessType(adapterType, isCopilot ? { provider: "acpx", acpxAgent: "copilot" } : {})) : undefined;
if (existing) { restoring = true; return existing; }
// A same-name record may already be complete; choose its provider before adopting it.
if (!sourceSelected && sameName.length) {
setSetupNameConflict(JSON.stringify([companyId, agentName.trim().toLowerCase()]));
return null;
}
}
const provisionalAdapter = disabledTypes.has("process") ? recommendedAdapters[0]?.type : "process";
if (!sourceSelected && !provisionalAdapter) throw new Error("No agent adapters are available on this instance.");
const createAdapter = sourceSelected ? adapterType : provisionalAdapter!;
if (!stillTheSameCompany(companyId)) return null;
setSetupAgentId(id);
return agentsApi.create(companyId, {
setupId: id, name: agentName.trim() || AGENT_ROLE_LABELS[agentRole], appearance: agentAppearance,
role: agentRole, adapterType: createAdapter, runner,
adapterConfig: createAdapter === "paperclip_runner" ? { provider: "acpx", acpxAgent: "copilot" } : {},
metadata: { setupAdapterPending: !sourceSelected },
onboardingFirstAgent: true, runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
});
})().then(agent => {
if (!agent || !stillTheSameCompany(companyId)) return;
if (agent.pauseReason === "setup" || agent.status === "pending_approval") {
setSetupAgentId(agent.id);
if (!restoring && agent.name) setAgentName(current => current.trim() === agentName.trim() ? agent.name : current);
if (restoring && agent.adapterConfig) {
setRestoredSetup(agent);
setAgentName(agent.name);
setAgentRole(agent.role);
setAgentAppearance(resolveAgentAppearance(agent.appearance, agent.id));
const config = agent.adapterConfig;
if (agent.metadata?.setupAdapterPending === true) {
setRunner("auto");
setModel("");
setCommand("");
setArgs("");
setUrl("");
} else {
setAdapterType(agentHarnessType(agent.adapterType, config) === "acpx:copilot" ? "paperclip_runner" : agentHarnessType(agent.adapterType, config) as AdapterType);
setModel(typeof config.model === "string" ? config.model : "");
setCommand(typeof config.command === "string" ? config.command : "");
setArgs(Array.isArray(config.args) ? config.args.join(" ") : "");
setUrl(typeof config.url === "string" ? config.url : "");
setRunner(agent.adapterType === "paperclip_runner" ? "paperclip" : "legacy");
const binding = agent.runtimeConfig?.aiConnection as AiConnectionBinding | undefined;
if (binding) setCredentialMode(binding.method === "api_key" ? "api" : "subscription");
else if ((config.env as Record<string, unknown> | undefined)?.[apiKeyEnvKeyFor(adapterType)]) setCredentialMode("api");
else if ((config.env as Record<string, unknown> | undefined)?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]
|| (config.env as Record<string, unknown> | undefined)?.CODEX_HOME) setCredentialMode("subscription");
if (config.provider === "acpx" && config.acpxAgent === "copilot") setCopilotConnection(binding);
}
}
} else setCreatedAgentId(agent.id);
setSetupPrepared(true);
void queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
}).catch(cause => {
if (!stillTheSameCompany(companyId)) return;
setSetupError(true);
setError(cause instanceof Error ? cause.message : "Could not create agent setup.");
}).finally(() => {
preparingSetup.current.delete(companyId);
setSetupAttempt(attempt => attempt + 1);
});
}, [step, createdCompanyId, agentName, adapterRegistryLoaded, sourceSelected, setupNameConflict, runnerDiscoveryReady, createdAgentId, setupPrepared, setupAttempt]);
/**
* Whether this step has a sign-in to do before it can hire.
@@ -1313,7 +1420,7 @@ function OnboardingWizardInner({
*/
const connectCollapsed =
connectPhase !== "idle" && connectPhase !== "unwindRow" && sourceSelected;
const connectProgress = adapterEnvLoading ? "Testing connection…" : loading ? "Connecting…" : null;
const connectProgress = sourceSelected && !setupPrepared && !createdAgentId && !setupError ? "Creating your agent…" : adapterEnvLoading ? "Testing connection…" : loading ? "Connecting…" : null;
const hasSavedSubscription = Boolean(savedSubscription || savedKeys.storedLogin.data ||
(credentialMode !== "api" && managedBindingForStep()));
const connectHasCard = credentialMode === "api" || connectStepNeedsLogin || connectStepHasNoSandbox || Boolean(connectProgress);
@@ -1457,7 +1564,7 @@ function OnboardingWizardInner({
*/
const connectSourceLabel = isCopilot ? "GitHub Copilot" : CONNECT_SOURCE_NAMES[adapterType] ?? adapterType;
const connectCta: { label: string; icon: FooterPrimaryIcon; disabled: boolean } =
connectProgress
setupError ? { label: "Retry", icon: "arrow", disabled: false } : connectProgress
? { label: adapterEnvLoading ? "Testing…" : connectProgress, icon: "spinner", disabled: true }
: connectPhase === "waiting"
? { label: "Waiting for code", icon: "spinner", disabled: true }
@@ -1474,7 +1581,7 @@ function OnboardingWizardInner({
label: "Connect",
icon: "arrow",
disabled:
!connectStepReady || (!isCopilot && credentialMode === "api" && !apiKey.trim() && !selectedApiKey),
!connectStepReady || (!isCopilot && credentialMode === "api" && !apiKey.trim() && !selectedApiKey && !restoredApiCredential && !managedBindingForStep()),
}
: // Nothing is chosen on arrival, and the row is what chooses. Until
// it has been answered the button has nothing to do.
@@ -1518,6 +1625,7 @@ function OnboardingWizardInner({
* it is meant to start, against a source with no credential.
*/
function handleConnectStepPrimary() {
if (setupError) { setSetupError(false); setSetupAttempt(value => value + 1); setError(null); return; }
// Mid-sequence the button belongs to the sign-in, not to the step.
if (connectPhase === "ready" && connectStepNeedsLogin) {
if (connectAuthUrl) window.open(connectAuthUrl, "_blank", "noreferrer,noopener");
@@ -1737,6 +1845,9 @@ function OnboardingWizardInner({
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
setCreatedAgentId(null);
setSetupAgentId(null);
setSetupPrepared(false);
setSetupError(false);
setCreatedCompanyGoalId(null);
setCreatedProjectId(null);
setCreatedIssueRef(null);
@@ -1913,7 +2024,10 @@ function OnboardingWizardInner({
function buildAdapterConfig(bindApiKey = false, setupRunner?: AgentRunner): Record<string, unknown> {
const adapter = getUIAdapter(adapterType);
const config = adapter.buildAdapterConfig({
const savedConfig = restoredSetup?.companyId === createdCompanyId
&& agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType, isCopilot ? { provider: "acpx", acpxAgent: "copilot" } : {})
? restoredSetup.adapterConfig : {};
const builtConfig = adapter.buildAdapterConfig({
...defaultCreateValues,
adapterType,
runner,
@@ -1939,6 +2053,16 @@ function OnboardingWizardInner({
? runner === "legacy" && DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
: defaultCreateValues.dangerouslyBypassSandbox
});
const savedEnv = { ...((savedConfig.env as Record<string, unknown>) ?? {}) };
if (adapterType === "claude_local") {
if (credentialMode === "api" || managedBindingForStep()) delete savedEnv[CLAUDE_OAUTH_TOKEN_ENV_KEY];
if (credentialMode === "subscription" || managedBindingForStep()) {
delete savedEnv.ANTHROPIC_API_KEY;
delete savedEnv.ANTHROPIC_AUTH_TOKEN;
}
}
const config = { ...savedConfig, ...builtConfig,
...((savedConfig.env || builtConfig.env) ? { env: { ...savedEnv, ...((builtConfig.env as object) ?? {}) } } : {}) };
if (adapterType === "claude_local" && forceUnsetAnthropicApiKey) {
const env =
typeof config.env === "object" &&
@@ -1966,7 +2090,9 @@ function OnboardingWizardInner({
// present. If storing failed this stays false, and the right outcome is a
// configuration with no credential — which the hire then blocks on — rather
// than one that quietly falls back to embedding the value.
if (!managedBindingForStep() && credentialMode === "api" && (bindApiKey || selectedApiKey)) {
if (!managedBindingForStep() && credentialMode === "api" && (bindApiKey || selectedApiKey)
&& (!((savedConfig.env as Record<string, unknown> | undefined)?.[apiKeyEnvKeyFor(adapterType)])
|| selectedSavedKey || apiKey.trim())) {
const env =
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
? { ...(config.env as Record<string, unknown>) }
@@ -1974,12 +2100,42 @@ function OnboardingWizardInner({
env[apiKeyEnvKeyFor(adapterType)] = selectedApiKey?.binding ?? apiKeySecretRef.current?.binding;
config.env = env;
}
if (credentialMode === "subscription" && savedSubscription?.binding) {
if (credentialMode === "subscription" && savedSubscription?.binding && !(savedConfig.env as Record<string, unknown> | undefined)?.CODEX_HOME) {
config.env = { ...((config.env as object) ?? {}), CODEX_HOME: savedSubscription.binding };
}
return config;
}
function buildSetupRuntimeConfig(heartbeatEnabled: boolean) {
const { aiConnection: _savedBinding, ...runtimeConfig } = restoredSetup?.companyId === createdCompanyId ? restoredSetup.runtimeConfig : {};
const binding = managedBindingForStep();
return { ...runtimeConfig, ...buildNewAgentRuntimeConfig({ heartbeatEnabled }), ...(binding ? { aiConnection: binding } : {}) };
}
const setupDetailsReady = runnerDiscoveryReady && (!isCopilot || copilotModelReady);
const setupSnapshot = step === 4 && setupPrepared && setupAgentId && !createdAgentId && !loading
? JSON.stringify({ name: agentName.trim() || AGENT_ROLE_LABELS[agentRole], appearance: agentAppearance,
...(sourceSelected && (setupDetailsReady || isCopilot) ? {
adapterType, runner,
adapterConfig: setupDetailsReady ? buildAdapterConfig(Boolean(apiKeySecretRef.current?.binding)) : { provider: "acpx", acpxAgent: "copilot" },
...(setupDetailsReady ? { defaultEnvironmentId: resolvedLoginEnvironmentId, runtimeConfig: buildSetupRuntimeConfig(false) } : {}),
metadata: { ...restoredSetup?.metadata, setupAdapterPending: false },
} : {}),
})
: null;
useEffect(() => {
if (!setupSnapshot || !setupAgentId || !createdCompanyId) return;
const companyId = createdCompanyId;
const id = setupAgentId;
setupSave.current = setupSave.current.catch(() => {}).then(async () => {
if (!stillTheSameCompany(companyId) || hiringAgentRef.current !== null) return;
await agentsApi.update(id, JSON.parse(setupSnapshot), companyId);
});
void setupSave.current.catch(cause => {
if (stillTheSameCompany(companyId)) setError(cause instanceof Error ? cause.message : "Could not save agent setup.");
});
}, [setupSnapshot, setupAgentId, createdCompanyId]);
async function runAdapterEnvironmentTest(
adapterConfigOverride?: Record<string, unknown>,
appliedStoredClaudeLoginBinding = false,
@@ -1999,11 +2155,6 @@ function OnboardingWizardInner({
setAdapterEnvLoading(true);
setAdapterEnvError(null);
try {
// Probe the environment a real run would use, so the Test matches a real
// run. The wizard has no agent yet, so the agent-default tier is always
// null; resolve the instance default and the instance local default. A
// settings-resolution failure surfaces an error instead of a silent host
// probe, which would report a false result.
let environmentList: Environment[];
let settings: InstanceSettings;
let managedSandboxOnly: boolean;
@@ -2038,7 +2189,7 @@ function OnboardingWizardInner({
// resolver throws when no managed sandbox is available, which the outer
// catch surfaces as a fail-closed error rather than a local host probe.
const environmentId = resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: null,
agentDefaultEnvironmentId: restoredSetup?.companyId === createdCompanyId ? restoredSetup.defaultEnvironmentId : null,
instanceDefaultEnvironmentId: settings?.defaultEnvironmentId ?? null,
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environmentList),
managedSandboxOnly,
@@ -2109,6 +2260,7 @@ function OnboardingWizardInner({
adapterType,
{
adapterConfig: testedConfig,
...(restoredSetup?.companyId === createdCompanyId ? { agentId: restoredSetup.id } : {}),
...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}),
environmentId,
@@ -2177,6 +2329,7 @@ function OnboardingWizardInner({
// doesn't hire a second agent.
async function handleGiveHeartbeat() {
if (!createdCompanyId) return;
if (!setupPrepared && !createdAgentId) return;
if (isCopilot && (!copilotOnboardingEnabled || !sourceSelected || !copilotModelReady)) {
setError("Connect GitHub Copilot and select an available model before creating this agent.");
return;
@@ -2336,10 +2489,11 @@ function OnboardingWizardInner({
// walks the step twice ends up with "Ada" and "Ada 2". An agent with the
// same name on the same source is that agent: adopt it and move on to
// Review, the way a run that remembers its hire does.
const existingAgents = await agentsApi.list(createdCompanyId).catch(() => null);
const existingAgents = setupAgentId ? null : await agentsApi.list(createdCompanyId).catch(() => null);
if (!isCurrent()) return;
const existing = existingAgents?.find(
(agent) =>
agent.id !== setupAgentId &&
agent.name.trim().toLowerCase() === hireName.toLowerCase() &&
agentHarnessType(agent.adapterType, agent.adapterConfig) === agentHarnessType(adapterType, hireAdapterConfig),
);
@@ -2357,12 +2511,16 @@ function OnboardingWizardInner({
// Invalidation prevents sending a hire for an abandoned verification.
// Once submitted, this existing API has no rollback contract; navigation
// can only suppress its completion, just as it does for a manual hire.
await setupSave.current.catch(() => {});
if (!isCurrent()) return;
const hire = await agentsApi.hire(createdCompanyId, {
...(setupAgentId ? { setupAgentId } : {}),
// The name is optional; an agent that reaches here without one is
// named for the job it was hired to do rather than left blank.
name: hireName,
appearance: agentAppearance,
role: agentRole,
...(restoredSetup?.companyId === createdCompanyId ? { reportsTo: restoredSetup.reportsTo } : {}),
adapterType,
adapterConfig: hireAdapterConfig,
...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
@@ -2371,7 +2529,8 @@ function OnboardingWizardInner({
// the chief-of-staff persona over the agent's entry instruction file.
// The wizard no longer composes or overwrites it.
onboardingFirstAgent: true,
runtimeConfig: { ...buildNewAgentRuntimeConfig(), ...(managedBinding ? { aiConnection: managedBinding } : {}) }
...(restoredSetup?.companyId === createdCompanyId && restoredSetup.defaultEnvironmentId ? { defaultEnvironmentId: resolvedLoginEnvironmentId } : {}),
runtimeConfig: buildSetupRuntimeConfig(true)
});
if (!isCurrent()) return;
if (hire.approval) {
@@ -2864,8 +3023,10 @@ function OnboardingWizardInner({
setSourcePicked(true);
setAdapterType(id);
if (id !== adapterType) setRunner("auto");
if (id === "opencode_local") setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
else if (id !== "codex_local") setModel("");
if (id !== adapterType) {
if (id === "opencode_local") setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
else if (id !== "codex_local") setModel("");
}
setConnectPhase("collapsing");
}}
/>
@@ -3,8 +3,11 @@ import { PAPERCLIP_RUNNER_ACPX_PROFILES } from "@paperclipai/adapter-utils";
import { useAgentAppearanceDraft } from "@/hooks/useAgentAppearanceDraft";
import { AdapterMark } from "../AdapterMark";
import { AgentCharacter } from "../AgentCharacter";
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import type { Agent } from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
import { buildNewAgentRuntimeConfig } from "@/lib/new-agent-runtime-config";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ArrowRight, Check, ChevronRight } from "lucide-react";
import { adaptersApi } from "@/api/adapters";
import { instanceSettingsApi } from "@/api/instanceSettings";
@@ -27,6 +30,8 @@ export type AgentBasics = {
name: string;
adapterType: string;
runnerProvider: string;
runner: string;
setupAgentId: string;
};
export { AdapterMark } from "../AdapterMark";
@@ -41,15 +46,29 @@ export function AgentBasicsDialog({
onClose,
onContinue,
initialAdapter = "",
initialName = "",
setupAgentId,
onInvite,
}: {
open: boolean;
onClose: () => void;
onContinue: (basics: AgentBasics) => void;
initialAdapter?: string;
initialName?: string;
setupAgentId?: string;
onInvite?: () => void;
}) {
const id = useId();
const { selectedCompanyId: companyId } = useCompany();
const cache = useQueryClient();
const appearanceDraft = useAgentAppearanceDraft(`${companyId}:new-agent`);
const setupId = useRef(setupAgentId ?? crypto.randomUUID());
const saved = useRef<Agent | null>(null);
const busy = useRef(false);
const active = useRef(true);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const cloud = Boolean(useCloudInstance());
const experimental = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
@@ -57,10 +76,10 @@ export function AgentBasicsDialog({
enabled: open,
retry: false,
});
const [name, setName] = useState("");
const [name, setName] = useState(initialName);
const [adapterType, setAdapterType] = useState(initialAdapter);
const [runnerProvider, setRunnerProvider] = useState("codex");
const [step, setStep] = useState<"name" | "adapter">("name");
const [step, setStep] = useState<"name" | "adapter">(setupAgentId ? "adapter" : "name");
const {
data: adapters,
isPending,
@@ -88,6 +107,64 @@ export function AgentBasicsDialog({
&& adapters?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled);
const validAdapter = choices.some((adapter) => adapter.type === adapterType)
|| (experimentalRunnerAvailable && adapterType === "paperclip_runner");
async function submit() {
if (busy.current || !name.trim() || (step === "adapter" && !validAdapter)) return;
busy.current = true;
setSaving(true);
setSaveError(null);
try {
if (!companyId) throw new Error("Select an organization to create an agent.");
let agent = saved.current;
if (!agent && setupAgentId) agent = await agentsApi.get(setupId.current, companyId);
const creatingRecord = !agent;
if (!agent) {
const existing = (await agentsApi.list(companyId)).filter(item => item.id !== setupId.current);
const leader = existing.find(item => item.role === "ceo" && item.status !== "terminated");
const provisionalAdapter = adapters?.some(item => item.type === "process" && item.disabled)
? choices[0]?.type : "process";
if (!provisionalAdapter) throw new Error("No agent adapters are available on this instance.");
// This adapter is provisional; the setup hold prevents execution.
agent = await agentsApi.create(companyId, {
setupId: setupId.current, name: name.trim(), appearance: appearanceDraft.appearance,
role: existing.length ? "general" : "ceo", ...(leader ? { reportsTo: leader.id } : {}),
adapterType: provisionalAdapter === "copilot_runtime" ? "paperclip_runner" : provisionalAdapter,
...(provisionalAdapter === "copilot_runtime" ? { adapterConfig: { provider: "acpx", acpxAgent: "copilot" } } : {}),
metadata: { setupAdapterPending: true },
runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
});
if (active.current) setName(agent.name);
}
saved.current = agent;
const nextAdapter = ["openai_dot", "copilot_runtime"].includes(adapterType) ? "paperclip_runner" : adapterType;
const nextProvider = adapterType === "copilot_runtime" ? "copilot" : adapterType === "openai_dot" ? "openai_dot" : adapterType === "paperclip_runner" ? runnerProvider : "codex";
if (step === "adapter" || (!creatingRecord && agent.name !== name.trim())) {
agent = await agentsApi.update(agent.id, {
name: name.trim(),
...(step === "adapter" ? {
adapterType: nextAdapter, runner: "auto" as const,
adapterConfig: nextAdapter === "paperclip_runner" ? {
provider: ["claude", "grok", "copilot"].includes(nextProvider) ? "acpx" : nextProvider,
...(["claude", "grok", "copilot"].includes(nextProvider) ? { acpxAgent: nextProvider } : {}),
...(nextProvider === "grok" ? { model: "grok-4.7" } : {}),
} : {},
metadata: { ...agent.metadata, setupAdapterPending: false },
} : {}),
}, companyId);
saved.current = agent;
}
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
void cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
if (!active.current) return;
if (step === "name") setStep("adapter");
else onContinue({ name: agent.name, adapterType: nextAdapter, runnerProvider: nextProvider,
runner: "auto", setupAgentId: agent.id });
} catch (cause) {
if (active.current) setSaveError(cause instanceof Error ? cause.message : "Could not save agent setup.");
} finally {
busy.current = false;
if (active.current) setSaving(false);
}
}
return (
<Dialog
open={open}
@@ -121,12 +198,7 @@ export function AgentBasicsDialog({
className="flex min-h-0 flex-col"
onSubmit={(event) => {
event.preventDefault();
if (!name.trim()) return;
if (step === "name") setStep("adapter");
else if (validAdapter)
onContinue({ name: name.trim(),
adapterType: ["openai_dot", "copilot_runtime"].includes(adapterType) ? "paperclip_runner" : adapterType,
runnerProvider: adapterType === "copilot_runtime" ? "copilot" : adapterType === "openai_dot" ? "openai_dot" : adapterType === "paperclip_runner" ? runnerProvider : "codex" });
void submit();
}}
>
<div className="flex min-h-0 flex-col gap-7 overflow-y-auto px-6 pb-8 sm:px-10">
@@ -153,6 +225,7 @@ export function AgentBasicsDialog({
<Input
id={id}
autoFocus
disabled={saving}
maxLength={100}
placeholder="e.g. Darnold"
value={name}
@@ -193,6 +266,7 @@ export function AgentBasicsDialog({
>
<input
type="radio"
disabled={saving}
name="new-agent-adapter"
value={adapter.type}
checked={adapterType === adapter.type}
@@ -236,10 +310,12 @@ export function AgentBasicsDialog({
</fieldset>
)}
</div>
{saveError && <p role="alert" className="px-6 pb-4 text-sm text-destructive">{saveError}</p>}
<div className="flex justify-between gap-4 border-t border-border px-6 py-4">
<Button
type="button"
variant="ghost"
disabled={saving}
onClick={() => (step === "name" ? onClose() : setStep("name"))}
>
{step === "name" ? (
@@ -253,9 +329,9 @@ export function AgentBasicsDialog({
</Button>
<Button
type="submit"
disabled={!name.trim() || (step === "adapter" && !validAdapter)}
disabled={saving || !name.trim() || (step === "adapter" && !validAdapter)}
>
{step === "name" ? "Choose adapter" : "Configure agent"}
{saving ? "Saving…" : step === "name" ? "Choose adapter" : "Configure agent"}
<ArrowRight className="size-4" />
</Button>
</div>
+158 -56
View File
@@ -31,6 +31,7 @@ import type {
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import { useNavigate, useSearchParams } from "@/lib/router";
import { agentsApi } from "@/api/agents";
import { ApiError } from "@/api/client";
import { adaptersApi } from "@/api/adapters";
import { environmentsApi } from "@/api/environments";
import { instanceSettingsApi } from "@/api/instanceSettings";
@@ -96,10 +97,11 @@ export function NewAgentSetup() {
companyId={selectedCompanyId}
name={params.get("name") ?? ""}
adapterType={params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex") ? params.get("runnerProvider") === "claude" ? "claude_local" : "codex_local" : params.get("adapterType") ?? ""}
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex") ? "paperclip" : "auto")}
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex") ? "paperclip" : params.has("setupAgentId") && ["codex_local", "claude_local"].includes(params.get("adapterType") ?? "") ? "legacy" : "auto")}
preserveNativeHarness={params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex")}
runnerProvider={params.get("runnerProvider") ?? "codex"}
createdAgentId={params.get("createdAgentId")}
setupAgentId={params.get("setupAgentId")}
/>
);
}
@@ -112,6 +114,7 @@ function Setup({
initialRunner,
preserveNativeHarness,
createdAgentId,
setupAgentId,
}: {
companyId: string;
name: string;
@@ -120,6 +123,7 @@ function Setup({
initialRunner: string;
preserveNativeHarness: boolean;
createdAgentId: string | null;
setupAgentId: string | null;
}) {
const navigate = useNavigate();
const cache = useQueryClient();
@@ -182,15 +186,23 @@ function Setup({
const [repository, setRepository] = useState("");
const [branch, setBranch] = useState("");
const [createdInSession, setCreated] = useState<Agent | null>(null);
const setupId = useRef(setupAgentId ?? crypto.randomUUID());
if (setupAgentId) setupId.current = setupAgentId;
const creating = useRef(false);
const restored = useRef<string | null>(null);
const [creationAttempt, setCreationAttempt] = useState(0);
const recordId = createdAgentId ?? setupAgentId;
const currentCreated = !recordId || createdInSession?.id === recordId ? createdInSession : null;
const savedAgent = useQuery({
queryKey: queryKeys.agents.detail(createdAgentId ?? "new"),
queryFn: () => agentsApi.get(createdAgentId!, companyId),
enabled: Boolean(createdAgentId),
queryKey: queryKeys.agents.detail(recordId ?? "new"),
queryFn: () => agentsApi.get(recordId!, companyId),
enabled: Boolean(recordId) && !currentCreated && !creating.current,
retry: false,
});
const created =
createdInSession ??
currentCreated ??
(savedAgent.data?.companyId === companyId ? savedAgent.data : null);
const completed = Boolean(created && created.pauseReason !== "setup");
const [saving, setSaving] = useState(false);
const [testState, setTestState] = useState<TestState | "warn">("idle");
const [result, setResult] = useState<AdapterEnvironmentTestResult | null>(
@@ -387,7 +399,7 @@ function Setup({
(!(managedOnly || forced.forced) || environmentId),
);
const needsCurrentSetup = ["codex_local", "claude_local"].includes(adapterType);
const ready = connectionSettingsReady && (!needsCurrentSetup || (
const ready = Boolean(created) && connectionSettingsReady && (!needsCurrentSetup || (
envs.isSuccess && !envs.isFetching
&& settings.isSuccess && !settings.isFetching
&& experimental.isSuccess && !experimental.isFetching
@@ -445,6 +457,7 @@ function Setup({
if (usingKimiApi) {
// --model overrides Kimi's environment-defined model. Let KIMI_MODEL_NAME win.
delete config.model;
config.kimiApi = { model: kimiModel.trim(), protocol: kimiProtocol, baseUrl: kimiBaseUrl.trim() };
config.env = {
...((config.env as object) ?? {}),
KIMI_MODEL_NAME: { type: "plain", value: kimiModel.trim() },
@@ -501,6 +514,112 @@ function Setup({
};
}
useEffect(() => {
if (!name || !adapterType || !connectionSettingsReady || createdAgentId || created || creating.current
|| (setupAgentId && !savedAgent.isError)) return;
if (savedAgent.error && (!(savedAgent.error instanceof ApiError) || savedAgent.error.status !== 404)) return;
creating.current = true;
setError(null);
navigate(`/agents/new?${new URLSearchParams({ name, adapterType, runnerProvider,
...(runner !== "auto" ? { runner } : {}), setupAgentId: setupId.current })}`, { replace: true });
const existing = agents.data ?? [];
const leader = existing.find(agent => agent.role === "ceo" && agent.status !== "terminated");
void agentsApi.create(companyId, {
setupId: setupId.current, name, appearance: appearanceDraft.appearance,
role: existing.length ? "general" : "ceo", ...(leader ? { reportsTo: leader.id } : {}),
adapterType: requestAdapterType, runner,
adapterConfig: requestAdapterType === "paperclip_runner" ? {
provider: ["claude", "grok", "copilot"].includes(runnerProvider) ? "acpx" : runnerProvider,
...(["claude", "grok", "copilot"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
} : {}, defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null),
runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
}).then(agent => {
setCreated(agent);
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
void cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
}).catch(cause => setError(cause instanceof Error ? cause.message : "Could not create the agent."))
.finally(() => { creating.current = false; });
}, [name, adapterType, companyId, connectionSettingsReady, creationAttempt, createdAgentId, setupAgentId, savedAgent.isError, created]);
useEffect(() => {
if (!savedAgent.data || restored.current === savedAgent.data.id || savedAgent.data.companyId !== companyId) return;
restored.current = savedAgent.data.id;
const agent = savedAgent.data;
if (agent.pauseReason !== "setup") { setScreen("saved"); return; }
const config = agent.adapterConfig;
setModel(typeof config.model === "string" ? config.model : "");
setEffort(String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? config.thinkingEffort ?? ""));
setEnvironmentOverride(agent.defaultEnvironmentId ?? "");
setRepository(typeof config.repoUrl === "string" ? config.repoUrl : "");
setBranch(typeof config.repoStartingRef === "string" ? config.repoStartingRef : "");
setGatewayUrl(typeof config.apiBaseUrl === "string" ? config.apiBaseUrl : "");
setAllowUnmeteredProvider(config.allowUnmeteredProvider === true);
const kimiApi = config.kimiApi as { model?: string; protocol?: string; baseUrl?: string } | undefined;
setKimiModel(kimiApi?.model ?? "");
setKimiProtocol(kimiApi?.protocol ?? "kimi");
setKimiBaseUrl(kimiApi?.baseUrl ?? "");
const env = typeof config.env === "object" && config.env !== null ? config.env as Record<string, unknown> : {};
const providerEntry = Object.entries(providerKeys).find(([, key]) => env[key]);
if (providerEntry) setProvider(providerEntry[0]);
const credential = config.apiKey ?? env[providerEntry?.[1] ?? envKey];
if (credential && typeof credential === "object" && "type" in credential
&& ["secret_ref", "user_secret_ref"].includes(String(credential.type))) setProviderBinding(credential as EnvBinding);
const binding = agent.runtimeConfig.aiConnection as AiConnectionBinding | undefined;
if (binding && connectionAdapter) setConnection({ env: {}, aiConnection: binding });
else if (binding) setRuntimeAiBinding(binding);
setScreen(binding || !connectionAdapter ? "runtime" : "connect");
}, [savedAgent.data, companyId, connectionAdapter]);
async function saveDraft(nextConnection = connection, exit = false, testedConfig?: Record<string, unknown>) {
if (!created || completed) return;
const binding = runtimeAiBinding ?? nextConnection?.aiConnection;
const config = testedConfig ?? buildConfig(nextConnection);
const staged: Array<{ remove: () => Promise<unknown> }> = [];
let submitted = false;
const rememberCredentials = () => {
if (!staged.length) return;
setApiKey("");
setProviderBinding((adapterType === "hermes_gateway" ? config.apiKey : (config.env as Record<string, EnvBinding>)?.[envKey]) as EnvBinding);
};
try {
for (const [key, value] of Object.entries(pendingCredentials(nextConnection))) {
const secret = await (connectionAdapter ? storeProviderApiKey : storeOrganizationApiKey)(companyId, key, value);
staged.push(secret);
if (adapterType === "hermes_gateway" && key === "API_SERVER_KEY") config.apiKey = secret.binding;
else config.env = { ...((config.env as object) ?? {}), [key]: secret.binding };
}
submitted = true;
const agent = await agentsApi.update(created.id, {
adapterType: requestAdapterType, runner, adapterConfig: config,
defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null),
runtimeConfig: { ...created.runtimeConfig, ...(binding ? { aiConnection: binding } : {}) },
...(nextConnection?.applyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}),
}, companyId);
rememberCredentials();
setCreated(agent);
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
if (exit) navigate("/agents/all");
return agent;
} catch (cause) {
if (submitted && !(cause instanceof ApiError && cause.status >= 400 && cause.status < 500 && cause.status !== 408)) {
rememberCredentials();
} else {
try { await Promise.all(staged.map(secret => secret.remove())); }
catch { throw new Error(`${cause instanceof Error ? cause.message : "Could not save agent setup."} Could not remove an unused setup credential. Remove it from Secrets before retrying.`); }
}
throw cause;
}
}
async function saveAndExit() {
if (savingRef.current) return;
savingRef.current = true;
setSaving(true);
try { await saveDraft(connection, true); }
catch (cause) { setError(cause instanceof Error ? cause.message : "Could not save agent setup."); }
finally { savingRef.current = false; setSaving(false); }
}
async function runTest(nextConnection = connection): Promise<Record<string, unknown> | null> {
if (!ready) return null;
const run = ++generation.current;
@@ -510,7 +629,7 @@ function Setup({
setError(null);
try {
preparedConfig(nextConnection);
const selection = { adapterType, runner, savedAgentId: createdAgentId, environment };
const selection = { adapterType, runner, savedAgentId: completed ? createdAgentId : null, environment };
const qualified = await qualifyClaudeSetup({ ...selection,
readAdapters: () => adaptersApi.list({ companyId, environmentId }),
probeEnvironment: () => environmentsApi.probe(environmentId!, companyId),
@@ -556,8 +675,8 @@ function Setup({
async function finish() {
if (
savingRef.current ||
createdAgentId ||
created ||
completed ||
!created ||
!ready ||
!name.trim() ||
testState === "running" ||
@@ -568,37 +687,19 @@ function Setup({
savingRef.current = true;
setSaving(true);
setError(null);
const staged: Array<{ remove: () => Promise<unknown> }> = [];
let hired = false;
try {
const credentials = pendingCredentials();
const config = (connectionAdapter && testState === "idle") || Object.keys(credentials).length
? await runTest()
: preparedConfig();
if (!config) return;
for (const [key, value] of Object.entries(credentials)) {
const store = connectionAdapter
? storeProviderApiKey
: storeOrganizationApiKey;
const secret = await store(companyId, key, value);
staged.push(secret);
if (adapterType === "hermes_gateway" && key === "API_SERVER_KEY")
config.apiKey = secret.binding;
else
config.env = {
...((config.env as object) ?? {}),
[key]: secret.binding,
};
}
const existing = agents.data ?? [];
const leader = existing.find(
(agent) => agent.role === "ceo" && agent.status !== "terminated",
);
if (Object.keys(credentials).length) await saveDraft(connection, false, config);
const response = await agentsApi.hire(companyId, {
name: name.trim(),
appearance: appearanceDraft.appearance,
role: existing.length ? "general" : "ceo",
...(leader ? { reportsTo: leader.id } : {}),
setupAgentId: created.id,
name: created.name,
appearance: created.appearance ?? appearanceDraft.appearance,
role: created.role,
reportsTo: created.reportsTo,
adapterType: requestAdapterType,
...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
adapterConfig: config,
@@ -614,7 +715,6 @@ function Setup({
? { applyStoredClaudeLogin: true }
: {}),
});
hired = true;
setApiKey("");
setConnection(null);
setCreated(response.agent);
@@ -639,16 +739,6 @@ function Setup({
cause instanceof Error ? cause.message : "Could not create the agent.",
);
} finally {
if (!hired) {
try {
await Promise.all(staged.map((secret) => secret.remove()));
} catch {
setError(
(original) =>
`${original ? `${original} ` : ""}Could not remove an unused setup credential. Remove it from Secrets before retrying.`,
);
}
}
void cache.invalidateQueries({
queryKey: queryKeys.secrets.myUserSecrets(companyId),
});
@@ -659,7 +749,7 @@ function Setup({
setSaving(false);
}
}
if (createdAgentId && !created)
if (recordId && !created && !creating.current && savedAgent.error && !(savedAgent.error instanceof ApiError && savedAgent.error.status === 404))
return (
<p
role={savedAgent.error ? "alert" : "status"}
@@ -670,11 +760,13 @@ function Setup({
: "Loading your agent…"}
</p>
);
if (!name || !adapterType)
if (!name || !adapterType || created?.metadata?.setupAdapterPending === true)
return (
<AgentBasicsDialog
open
initialAdapter={adapterType}
initialAdapter={created?.metadata?.setupAdapterPending ? "" : adapterType}
initialName={created?.name ?? name}
setupAgentId={created?.metadata?.setupAdapterPending ? created.id : setupAgentId ?? undefined}
onClose={() => navigate("/agents/all")}
onContinue={(basics) =>
navigate(`/agents/new?${new URLSearchParams(basics)}`, {
@@ -683,6 +775,10 @@ function Setup({
}
/>
);
if (!created && connectionSettingsReady) return <div className="space-y-4">
<p role={error ? "alert" : "status"}>{error ?? "Creating your agent…"}</p>
{error && <Button onClick={() => setCreationAttempt(value => value + 1)}>Retry</Button>}
</div>;
const steps = [
...(connectionAdapter ? ["connect" as const] : []),
"runtime" as const,
@@ -701,6 +797,7 @@ function Setup({
)?.KIMI_MODEL_NAME;
const confirmationModel =
created?.adapterConfig?.model ||
(created?.adapterConfig?.kimiApi as { model?: string } | undefined)?.model ||
(typeof createdKimiModel === "string"
? createdKimiModel
: (createdKimiModel as { value?: string } | undefined)?.value) ||
@@ -728,8 +825,8 @@ function Setup({
<div className="mx-auto flex max-w-5xl flex-col gap-8 py-6">
<header className="flex items-center gap-4">
<AgentCharacter appearance={created?.appearance ?? appearanceDraft.appearance} size={256} className="size-48" trackingScope="page"
state={created || testState === "pass" ? "success" : testState === "running" ? "loading" : "sleepy"}
muted={!created && testState !== "pass"} />
state={completed || testState === "pass" ? "success" : testState === "running" ? "loading" : "sleepy"}
muted={!completed && testState !== "pass"} />
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{name}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -778,7 +875,7 @@ function Setup({
type="button"
aria-current={step === screen ? "step" : undefined}
disabled={
busy || Boolean(created) || step === "saved"
busy || completed || step === "saved"
}
onClick={() => step === "connect" && screen !== "connect" ? editConnection() : setScreen(step)}
className={cn(
@@ -842,7 +939,7 @@ function Setup({
/>
),
}}
onBack={() => navigate("/agents/all")}
onBack={() => void saveAndExit()}
onDraftChanged={() => {
setConnection(null);
setRuntimeAiBinding(undefined);
@@ -863,9 +960,11 @@ function Setup({
)?.message
}
onConnected={(next) => {
setConnection(next);
setRuntimeAiBinding(undefined);
setScreen("runtime");
void saveDraft(next).then(() => {
setConnection(next);
setRuntimeAiBinding(undefined);
setScreen("runtime");
}).catch(cause => setError(cause instanceof Error ? cause.message : "Could not save agent setup."));
}}
/></fieldset> : <p role="status" className="text-sm text-muted-foreground">Loading connection settings…</p>}
{["codex_local", "claude_local"].includes(adapterType) && <details className="mt-5 space-y-3">
@@ -1276,6 +1375,8 @@ function Setup({
</p>
)}
<div className="flex justify-between border-t border-border py-5">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" disabled={busy} onClick={() => void saveAndExit()}>Save & exit</Button>
{connectionAdapter ? (
<Button
type="button"
@@ -1287,8 +1388,9 @@ function Setup({
Connection
</Button>
) : (
<span />
null
)}
</div>
{connectionAdapter && !connection ? <Button
type="button"
disabled={!ready || busy}
@@ -1305,7 +1407,7 @@ function Setup({
Boolean(connectionAdapter && !connection)
}
>
{saving ? "Creating…" : "Finish setup"}
{saving ? "Saving…" : "Finish setup"}
<Check className="size-4" />
</Button>}
</div>
+20 -1
View File
@@ -8,6 +8,8 @@ import { NewAgent } from "./NewAgent";
import { ApiError } from "@/api/client";
const api = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
get: vi.fn(),
adapterModels: vi.fn(),
list: vi.fn(),
@@ -98,6 +100,7 @@ vi.mock("motion/react", () => ({
let root: Root;
let container: HTMLDivElement;
let cache: QueryClient;
let setupRecords: Map<string, Record<string, unknown>>;
async function settle() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
@@ -172,12 +175,28 @@ beforeEach(() => {
"paperclip_runner", "cursor_cloud", "cursor", "gemini_local", "kimi_local", "grok_local", "hermes_local", "hermes_gateway",
].map((type) => ({ type, loaded: true, disabled: false }));
api.adapterModels.mockResolvedValue([]);
setupRecords = new Map();
api.create.mockImplementation(async (_company, input) => {
const record = { ...input, id: input.setupId, companyId: "company-1", status: "paused", lifecycleState: "paused", pauseReason: "setup" };
setupRecords.set(record.id, record);
return record;
});
api.get.mockImplementation(async id => {
const record = setupRecords.get(id);
if (!record) throw new ApiError("Not found", 404, null);
return record;
});
api.update.mockImplementation(async (id, input) => {
const record = { ...setupRecords.get(id), ...input, id, companyId: "company-1", status: "paused", lifecycleState: "paused", pauseReason: "setup" };
setupRecords.set(id, record);
return record;
});
api.list.mockResolvedValue([{ id: "ceo", role: "ceo", status: "idle" }]);
api.getAdapterAuthSignal.mockResolvedValue({ status: "present" });
api.getClaudeOAuthTokenStatus.mockRejectedValue(new ApiError("Not found", 404, null));
api.testEnvironment.mockResolvedValue(pass);
api.hire.mockImplementation(async (_company, input) => ({
agent: { ...input, id: "new-agent", status: "idle", urlKey: "atlas" },
agent: { ...input, id: input.setupAgentId ?? "new-agent", status: "idle", urlKey: "atlas" },
}));
envApi.list.mockResolvedValue([
{ id: "local-1", name: "Local", driver: "local", config: {} },
+211 -4
View File
@@ -10,6 +10,136 @@ import {
} from "./NewAgent.test-support";
describe("New agent setup", () => {
it("creates an inactive record before connecting and finishes that same agent", async () => {
await render("codex_local");
expect(api.create).toHaveBeenCalledTimes(1);
const setupId = api.create.mock.calls[0][1].setupId;
expect(api.create.mock.calls[0][1]).toMatchObject({ name: "Atlas", runtimeConfig: { heartbeat: { enabled: false } } });
expect(state.navigate).toHaveBeenCalledWith(expect.stringContaining(`setupAgentId=${setupId}`), { replace: true });
expect(api.hire).not.toHaveBeenCalled();
expect(api.testEnvironment).not.toHaveBeenCalled();
await click("OpenAIAPI key");
await fill("API key", "setup-test-key");
await click("Connect");
expect(api.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: expect.anything() }) }), "company-1");
await click("Finish setup");
expect(api.hire.mock.calls[0][1].setupAgentId).toBe(setupId);
expect(api.create).toHaveBeenCalledTimes(1);
});
it("resumes a name-only draft at adapter selection without creating another record", async () => {
const setupId = "00000000-0000-4000-8000-000000000091";
state.params = new URLSearchParams({ name: "Atlas", adapterType: "process", setupAgentId: setupId });
api.get.mockResolvedValue({ id: setupId, companyId: "company-1", name: "Atlas", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, metadata: { setupAdapterPending: true }, pauseReason: "setup", status: "paused" });
await renderCurrentParams();
await settle();
expect(document.body.textContent).toContain("Choose an adapter");
expect(api.create).not.toHaveBeenCalled();
expect(api.testEnvironment).not.toHaveBeenCalled();
await act(async () => document.querySelector<HTMLInputElement>('input[name="new-agent-adapter"][value="codex_local"]')!.click());
await act(async () => [...document.querySelectorAll("button")].find(button => button.textContent?.trim() === "Configure agent")!.click());
await settle();
expect(api.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ adapterType: "codex_local", metadata: { setupAdapterPending: false } }), "company-1");
expect(state.navigate).toHaveBeenLastCalledWith(expect.stringContaining(`setupAgentId=${setupId}`), { replace: true });
expect(api.create).not.toHaveBeenCalled();
});
it("retries early creation with the same identity before offering authentication", async () => {
api.create.mockRejectedValueOnce(new Error("Creation unavailable"));
await render("codex_local");
expect(container.textContent).toContain("Creation unavailable");
expect(container.textContent).not.toContain("Complete subscription login");
await click("Retry");
expect(api.create).toHaveBeenCalledTimes(2);
expect(api.create.mock.calls[1][1].setupId).toBe(api.create.mock.calls[0][1].setupId);
expect(container.textContent).toContain("Connect a model");
});
it("resumes a saved setup record and its configuration after navigation", async () => {
await render("codex_local");
await click("OpenAIAPI key");
await fill("API key", "setup-test-key");
await click("Connect");
await fill("Model", "gpt-5.4");
await click("Save & exit");
const setupId = api.create.mock.calls[0][1].setupId;
expect(state.navigate).toHaveBeenLastCalledWith("/agents/all");
await act(async () => root.render(null));
cache.clear();
state.params = new URLSearchParams({ name: "Atlas", adapterType: "codex_local", setupAgentId: setupId });
await renderCurrentParams();
await settle();
expect(container.textContent).toContain("Configure your agent");
expect(container.textContent).not.toContain("Your agent is ready");
expect((container.querySelector('[aria-label="Model"]') as HTMLInputElement).value).toBe("gpt-5.4");
await click("Finish setup");
expect(api.hire.mock.calls[0][1]).toMatchObject({ setupAgentId: setupId, adapterConfig: { model: "gpt-5.4" } });
expect(api.create).toHaveBeenCalledTimes(1);
});
it("retains the first agent's role when finishing its setup", async () => {
api.list.mockResolvedValue([]);
await render("codex_local");
await connect("OpenAI");
api.list.mockResolvedValue([{ id: api.create.mock.calls[0][1].setupId, role: "ceo", status: "paused" }]);
await act(async () => { await cache.invalidateQueries({ queryKey: queryKeys.agents.list("company-1") }); });
await settle();
await click("Finish setup");
expect(api.hire.mock.calls[0][1].role).toBe("ceo");
});
it("keeps the setup identity when finalization fails and is retried", async () => {
await render("codex_local");
await connect("OpenAI");
api.hire.mockRejectedValueOnce(new Error("Hire unavailable"));
await click("Finish setup");
expect(container.textContent).toContain("Hire unavailable");
await click("Finish setup");
expect(api.hire).toHaveBeenCalledTimes(2);
expect(api.hire.mock.calls[1][1].setupAgentId).toBe(api.hire.mock.calls[0][1].setupAgentId);
expect(api.create).toHaveBeenCalledTimes(1);
});
it("carries the existing Claude stored-login authorization into a saved setup", async () => {
api.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "stored-oauth", latestVersion: 1 });
await render("claude_local");
await click("ClaudeSubscription");
await click("Use saved subscription");
expect(api.update.mock.calls[0][1].applyStoredClaudeLogin).toBe(true);
});
it("saves a typed gateway key as a secret and restores it without re-entry", async () => {
await render("hermes_gateway");
await fill("Hermes API base URL", "https://hermes.example.test");
await fill("API_SERVER_KEY", "saved-gateway-key");
await click("Save & exit");
const setupId = api.create.mock.calls[0][1].setupId;
expect(api.update.mock.calls.at(-1)![1].adapterConfig.apiKey).toMatchObject({ type: "secret_ref", secretId: "org-secret-1" });
expect(JSON.stringify(api.update.mock.calls)).not.toContain("saved-gateway-key");
await act(async () => root.render(null));
cache.clear();
state.params = new URLSearchParams({ name: "Atlas", adapterType: "hermes_gateway", setupAgentId: setupId });
await renderCurrentParams();
await settle();
await click("Finish setup");
expect(api.hire.mock.calls[0][1].adapterConfig.apiKey).toMatchObject({ type: "secret_ref", secretId: "org-secret-1" });
expect(secrets.create).toHaveBeenCalledTimes(1);
});
it("retains an unfinished legacy harness when the current target defaults to Paperclip Runner", async () => {
await render("codex_local");
const setupId = api.create.mock.calls[0][1].setupId;
await act(async () => root.render(null));
cache.clear();
state.adapters = state.adapters.map((adapter: any) => adapter.type === "codex_local" ? { ...adapter, defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] } : adapter);
state.params = new URLSearchParams({ name: "Atlas", adapterType: "codex_local", setupAgentId: setupId });
await renderCurrentParams();
await settle();
await connect("OpenAI");
await click("Finish setup");
expect(api.hire.mock.calls[0][1]).toMatchObject({ adapterType: "codex_local", runner: "legacy" });
});
it.each([
["paperclip", "Connect", "image"], ["legacy", "Connect", "image"],
["paperclip", "Finish setup", "image"], ["legacy", "Finish setup", "image"],
@@ -612,15 +742,62 @@ describe("New agent setup", () => {
expect(container.textContent).toContain("Enter the Kimi API model name");
await fill("Kimi API model name", "kimi-for-coding");
await fill("Kimi API base URL", "https://api.kimi.com/coding/v1");
const persist = api.update.getMockImplementation()!;
api.update.mockImplementationOnce(async (...args) => {
const saved = await persist(...args);
return { ...saved, adapterConfig: { ...saved.adapterConfig, env: Object.fromEntries(
Object.entries(saved.adapterConfig.env).map(([key, binding]) => [key,
(binding as { type: string }).type === "plain" ? { type: "plain", value: "***REDACTED***" } : binding]),
) } };
});
await click("Finish setup");
const config = api.hire.mock.calls[0][1].adapterConfig;
expect(config).not.toHaveProperty("model");
expect(config.env).toMatchObject({
KIMI_MODEL_NAME: { type: "plain", value: "kimi-for-coding" },
KIMI_MODEL_PROVIDER_TYPE: { type: "plain", value: "kimi" },
KIMI_MODEL_BASE_URL: { type: "plain", value: "https://api.kimi.com/coding/v1" },
KIMI_MODEL_API_KEY: { type: "secret_ref", secretId: "org-secret-1" },
});
});
it("restores saved Kimi API settings even when the API redacts plain environment values", async () => {
await render("kimi_local");
await fill("KIMI_MODEL_API_KEY", "kimi-test-key");
await fill("Kimi API model name", "kimi-for-coding");
await act(async () => {
const protocol = container.querySelector('[aria-label="Kimi API protocol"]') as HTMLSelectElement;
protocol.value = "openai";
protocol.dispatchEvent(new Event("change", { bubbles: true }));
});
await fill("Kimi API base URL", "https://kimi.example.test/v1");
await click("Save & exit");
const setupId = api.create.mock.calls[0][1].setupId;
const get = api.get.getMockImplementation()!;
api.get.mockImplementation(async (...args) => {
const saved = await get(...args);
return { ...saved, adapterConfig: { ...saved.adapterConfig, env: Object.fromEntries(
Object.entries(saved.adapterConfig.env).map(([key, binding]) => [key,
(binding as { type: string }).type === "plain" ? { type: "plain", value: "***REDACTED***" } : binding]),
) } };
});
await act(async () => root.render(null));
cache.clear();
state.params = new URLSearchParams({ name: "Atlas", adapterType: "kimi_local", setupAgentId: setupId });
await renderCurrentParams();
await settle();
expect((container.querySelector('[aria-label="Kimi API model name"]') as HTMLInputElement).value).toBe("kimi-for-coding");
expect((container.querySelector('[aria-label="Kimi API protocol"]') as HTMLSelectElement).value).toBe("openai");
await click("Save & exit");
expect(api.update.mock.calls.at(-1)![1].adapterConfig.env).toMatchObject({
KIMI_MODEL_NAME: { type: "plain", value: "kimi-for-coding" },
KIMI_MODEL_PROVIDER_TYPE: { type: "plain", value: "openai" },
KIMI_MODEL_BASE_URL: { type: "plain", value: "https://kimi.example.test/v1" },
});
await click("Finish setup");
expect(api.hire.mock.calls[0][1].adapterConfig.env).toMatchObject({ KIMI_MODEL_NAME: { type: "plain", value: "kimi-for-coding" } });
expect(secrets.create).toHaveBeenCalledTimes(1);
});
it("configures Hermes Gateway URL and its top-level secret reference", async () => {
await render("hermes_gateway");
expect(container.querySelector('[aria-label="Model"]')).toBeNull();
@@ -705,7 +882,7 @@ describe("New agent setup", () => {
expect(container.textContent).toContain("Your agent is ready");
await click("Assign Atlas a Task");
expect(state.openNewIssue).toHaveBeenCalledWith({
assigneeAgentId: "new-agent",
assigneeAgentId: api.create.mock.calls[0][1].setupId,
status: "todo",
});
},
@@ -953,11 +1130,11 @@ describe("New agent setup", () => {
await act(async () => root.render(null));
expect(secrets.createUserSecretDefinition).not.toHaveBeenCalled();
});
it("removes the staged credential when hiring fails", async () => {
it("removes the staged credential when saving setup fails", async () => {
await render();
await fill("Model", "openrouter/anthropic/claude-sonnet-4.6");
await fill("OPENROUTER_API_KEY", "new-key");
api.hire.mockRejectedValueOnce(new Error("Creation rejected"));
api.update.mockRejectedValueOnce(new ApiError("Creation rejected", 422, null));
await click("Finish setup");
expect(secrets.remove).toHaveBeenCalledWith("org-secret-1");
expect(container.textContent).toContain("Creation rejected");
@@ -966,12 +1143,42 @@ describe("New agent setup", () => {
await render();
await fill("Model", "openrouter/anthropic/claude-sonnet-4.6");
await fill("OPENROUTER_API_KEY", "new-key");
api.hire.mockRejectedValueOnce(new Error("Agent quota exceeded"));
api.update.mockRejectedValueOnce(new ApiError("Agent quota exceeded", 422, null));
secrets.remove.mockRejectedValueOnce(new Error("Cleanup unavailable"));
await click("Finish setup");
expect(container.textContent).toContain("Agent quota exceeded");
expect(container.textContent).toContain("Could not remove an unused setup credential");
});
it("retains the credential on the setup record when the hire response is lost", async () => {
await render();
await fill("Model", "openrouter/anthropic/claude-sonnet-4.6");
await fill("OPENROUTER_API_KEY", "new-key");
api.hire.mockRejectedValueOnce(new Error("Response lost"));
await click("Finish setup");
expect(secrets.remove).not.toHaveBeenCalled();
expect(api.update.mock.calls.at(-1)![1].adapterConfig.env.OPENROUTER_API_KEY).toMatchObject({ type: "secret_ref" });
await click("Finish setup");
expect(secrets.create).toHaveBeenCalledTimes(1);
expect(api.hire).toHaveBeenCalledTimes(2);
});
it.each(["Save & exit", "Finish setup"])("retains a saved key when the %s PATCH response is lost", async action => {
await render("hermes_gateway");
await fill("Hermes API base URL", "https://hermes.example.test");
await fill("API_SERVER_KEY", "uncertain-save-key");
const persist = api.update.getMockImplementation()!;
api.update.mockImplementationOnce(async (...args) => {
await persist(...args);
throw new Error("Save response lost");
});
await click(action);
expect(container.textContent).toContain("Save response lost");
expect(secrets.remove).not.toHaveBeenCalled();
const setupId = api.create.mock.calls[0][1].setupId;
expect((await api.get(setupId)).adapterConfig.apiKey).toMatchObject({ type: "secret_ref", secretId: "org-secret-1" });
await click("Finish setup");
expect(api.hire.mock.calls[0][1].adapterConfig.apiKey).toMatchObject({ type: "secret_ref", secretId: "org-secret-1" });
expect(secrets.create).toHaveBeenCalledTimes(1);
});
it("requires an explicit provider/model for Pi", async () => {
await render();
await click("Run test");