diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index ba4fd235f72..cd778c77f0f 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -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` diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index e486e02cfb1..4e46b700ab9 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -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 diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index b092c58d7bf..275a03028b4 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -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. diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 5bb9bf3484c..a9711dcfc7e 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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 = [ diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 575bfc551c5..edbcf18c7f4 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -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; -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; export const updateAgentSchema = objectWithoutDefaults( - createAgentSchema.omit({ permissions: true, onboardingFirstAgent: true }), + createAgentSchema.omit({ permissions: true, onboardingFirstAgent: true, setupId: true }), ) .partial() .extend({ diff --git a/server/src/__tests__/agent-auth-middleware.test.ts b/server/src/__tests__/agent-auth-middleware.test.ts index 087846195c8..e0bfc15a6eb 100644 --- a/server/src/__tests__/agent-auth-middleware.test.ts +++ b/server/src/__tests__/agent-auth-middleware.test.ts @@ -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; resultJson?: Record }; @@ -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(); diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 9ca9c2d51df..4f103218f30 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -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)); diff --git a/server/src/lib/agent-records.ts b/server/src/lib/agent-records.ts index 354d4268f8e..01b41aaa9c0 100644 --- a/server/src/lib/agent-records.ts +++ b/server/src/lib/agent-records.ts @@ -87,6 +87,8 @@ export type AgentConfigurationPatch = Partial; export interface CreateAgentOptions { + setupOnly?: boolean; + setupAgentId?: string; runnerResolved?: boolean; responsibleUserId?: string | null; createdByUserId?: string | null; diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index d72d3d3cd1e..8504fc3c853 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -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; diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts index 8082f7594c2..909a1f38f5e 100644 --- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts +++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts @@ -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, diff --git a/server/src/modules/agent-lifecycle/adapters/postgres.ts b/server/src/modules/agent-lifecycle/adapters/postgres.ts index a5cde41e748..c1cce302d00 100644 --- a/server/src/modules/agent-lifecycle/adapters/postgres.ts +++ b/server/src/modules/agent-lifecycle/adapters/postgres.ts @@ -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 || diff --git a/server/src/modules/agent-lifecycle/adapters/records.ts b/server/src/modules/agent-lifecycle/adapters/records.ts index 131f8befc06..f3b371b9ad5 100644 --- a/server/src/modules/agent-lifecycle/adapters/records.ts +++ b/server/src/modules/agent-lifecycle/adapters/records.ts @@ -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); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 2ca76427c6a..5b6b2ca5059 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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; 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; 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; 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; + 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; 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); diff --git a/server/src/services/agent-hiring.ts b/server/src/services/agent-hiring.ts index 4e0199a1ade..00ed74c6ab3 100644 --- a/server/src/services/agent-hiring.ts +++ b/server/src/services/agent-hiring.ts @@ -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); } } diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index d3bcaca2b11..4c4cbd7248f 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -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"); } diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index e311961d238..4f8299ade88 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -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(); + }); + }); diff --git a/ui/src/components/AgentActionButtons.test.tsx b/ui/src/components/AgentActionButtons.test.tsx index 322f673181c..08648570fb9 100644 --- a/ui/src/components/AgentActionButtons.test.tsx +++ b/ui/src/components/AgentActionButtons.test.tsx @@ -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(); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index 860327ec39a..88031d22aa4 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -433,6 +433,12 @@ export function AgentActionButtons({ disabled={clearErrorDisabled} size={size} /> + ) : agent.pauseReason === "setup" && agent.lifecycleState === "paused" ? ( + ) : ( ({ 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('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('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('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('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 () => { diff --git a/ui/src/components/OnboardingWizard.step.test.tsx b/ui/src/components/OnboardingWizard.step.test.tsx index 41dc21c80ac..96ded1bcbb0 100644 --- a/ui/src/components/OnboardingWizard.step.test.tsx +++ b/ui/src/components/OnboardingWizard.step.test.tsx @@ -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 () => { diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 5fed69b1089..a76bdeb6673 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -79,6 +79,8 @@ const mockGoalsApi = vi.hoisted(() => ({ list: vi.fn(async () => []), })); const mockAgentsApi = vi.hoisted(() => ({ + create: vi.fn(async (_company: string, input: Record) => ({ ...input, id: input.setupId, pauseReason: "setup" })), + update: vi.fn(async (_id: string, input: Record) => input), adapterModels: vi.fn(async () => [] as Array<{ id: string; label: string }>), testEnvironment: vi.fn( async (): Promise => ({ @@ -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 = {}) { 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; 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 }]>).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 } }).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()); + 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" }); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index f13da68eb94..9934612f059 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -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(null); const isCopilot = adapterType === "paperclip_runner"; const [copilotConnection, setCopilotConnection] = useState(); 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((saved?.createdAgentId as string) ?? null); + const [setupAgentId, setSetupAgentId] = useState((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()); + const [restoredSetup, setRestoredSetup] = useState(null); + const setupSave = useRef>(Promise.resolve()); const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState( (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 | 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 | 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 | undefined)?.[apiKeyEnvKeyFor(adapterType)]) setCredentialMode("api"); + else if ((config.env as Record | undefined)?.[CLAUDE_OAUTH_TOKEN_ENV_KEY] + || (config.env as Record | 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 { 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) ?? {}) }; + 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 | undefined)?.[apiKeyEnvKeyFor(adapterType)]) + || selectedSavedKey || apiKey.trim())) { const env = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? { ...(config.env as Record) } @@ -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 | 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, 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"); }} /> diff --git a/ui/src/components/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx index f126ef36e93..89090dbd4af 100644 --- a/ui/src/components/new-agent/AgentBasicsDialog.tsx +++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx @@ -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(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(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 ( { 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(); }} >
@@ -153,6 +225,7 @@ export function AgentBasicsDialog({ )}
+ {saveError &&

{saveError}

}
diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index d39ad95591b..b99074e2b17 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -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(null); + const setupId = useRef(setupAgentId ?? crypto.randomUUID()); + if (setupAgentId) setupId.current = setupAgentId; + const creating = useRef(false); + const restored = useRef(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("idle"); const [result, setResult] = useState( @@ -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 : {}; + 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) { + if (!created || completed) return; + const binding = runtimeAiBinding ?? nextConnection?.aiConnection; + const config = testedConfig ?? buildConfig(nextConnection); + const staged: Array<{ remove: () => Promise }> = []; + let submitted = false; + const rememberCredentials = () => { + if (!staged.length) return; + setApiKey(""); + setProviderBinding((adapterType === "hermes_gateway" ? config.apiKey : (config.env as Record)?.[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 | 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 }> = []; - 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 (

); - if (!name || !adapterType) + if (!name || !adapterType || created?.metadata?.setupAdapterPending === true) return ( navigate("/agents/all")} onContinue={(basics) => navigate(`/agents/new?${new URLSearchParams(basics)}`, { @@ -683,6 +775,10 @@ function Setup({ } /> ); + if (!created && connectionSettingsReady) return

+

{error ?? "Creating your agent…"}

+ {error && } +
; 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({
+ state={completed || testState === "pass" ? "success" : testState === "running" ? "loading" : "sleepy"} + muted={!completed && testState !== "pass"} />

{name}

@@ -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.")); }} /> :

Loading connection settings…

} {["codex_local", "claude_local"].includes(adapterType) &&
@@ -1276,6 +1375,8 @@ function Setup({

)}
+
+ {connectionAdapter ? (
{connectionAdapter && !connection ? }
diff --git a/ui/src/pages/NewAgent.test-support.tsx b/ui/src/pages/NewAgent.test-support.tsx index beab1997d3e..ff0cf4b2d1d 100644 --- a/ui/src/pages/NewAgent.test-support.tsx +++ b/ui/src/pages/NewAgent.test-support.tsx @@ -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>; 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: {} }, diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index bae1475d399..cedd0c00dd5 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -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('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");