From cb291bf1298ab484a2792c05eb977f3440f1f351 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:19:33 -0500 Subject: [PATCH] Fix Dot Cloud invitation and anonymous consent setup (#15722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Dot joins through a public MCP connection and the Runner task system. > - A new hire must connect before its lifecycle can become ready. > - The invite UI treated the hire's temporary paused status as a manual pause. > - Anonymous Cloud consent also reloaded after protected background probes returned 401. > - This pull request fixes both gates without changing server access checks. > - The benefit is that a new Dot can complete Cloud setup and the UI waits for actual readiness. ## Linked Issues or Issue Description Related merged work: #15691 and #15581. I searched open Dot PR and issue titles. I found no duplicate. **What happened?** A new Dot invite displayed “Resume this agent before connecting Dot.” The hire was preparing or verifying. The anonymous Cloud consent page remained on “Loading connection request…” because background tenant-session failures caused repeated document reloads. **Expected behavior** The invite must issue a setup prompt when the server permits connection setup. Public Dot consent must load without a board session. The Done button must wait for both the binding and agent lifecycle to become ready. **Steps to reproduce** 1. Enable Dot, Assistant connections (MCP), and the native Runner on a Cloud test tenant. 2. Choose New Agent, Invite an external agent, then Dot. 3. Observe the false resume gate on the new hire. 4. Open the plugin's public Dot consent page in a browser without a tenant board session. 5. Observe the loading loop after a protected background probe returns `tenant_session_required`. **Paperclip version or commit** Reproduced on `556daf9a99e8674f9c4a1c38ac20ae9adafdd417`. **Deployment mode** Cloud staging with a managed Daytona Runner. No production tenant changes. ## What Changed - Return lifecycle state and connection setup authority in the operator Dot binding response. - Use that authority to prepare prompts during hire preparation and verification. - Continue polling after event readiness until the agent lifecycle is ready. - Keep expected tenant-session failures from reloading an exact public Dot consent route. - Classify Dot consent as a global route. Retain board session and archived-stack recovery. - Add regression tests and update the Dot deployment documentation. ## Verification - Before the fix, regression tests reproduced the false pairing gate and anonymous consent reload. - After the fix, 34 Dot invitation tests and 51 public consent/API/route tests passed before rebasing. - Full workspace typecheck, full build, final UI typecheck, and design token gates passed locally. All current-head CI gates are green, including full tests and browser E2E. - The final UI regression run passed 80 tests. The broad local test run has reported failures in unchanged chat, OAuth-tool-access, and native-resume suites while running alongside other development instances; the equivalent isolated CI lanes passed. The broad local run was stopped after these failures to release local resources; it is not claimed as passing. - Live Cloud qualification passed OAuth discovery, the approved agent grant, ChatGPT account handoff, a signed event subscription, and the inbox readiness challenge. The agent became ready and normal scheduling launched the managed Runner. A real Dot accepted the task, read its context, wrote and verified a document, posted a comment, and completed through paperclip_finish and paperclip_dot_finish. The task persisted as Done after reload, and the successful turn's sandbox was destroyed with a successful cleanup receipt. - Restarting the existing Dot refreshed its newly installed plugin actions. The first Daytona allocation remained creating and hit the acquisition deadline; a bounded normal retry acquired a sandbox and launched through authenticated provider ingress. The earlier allocation still rejects deletion while creating (HTTP 409), so its failed-run cleanup remains pending. - After deployment, use a real Dot to open the consent page without board credentials, pair, confirm task events, and finish an assigned task through the managed Runner. ## Risks The operator binding response has two new required fields. Server and UI must deploy together. The recovery exception only applies to a valid Dot consent pathname and tenant-session 401 responses. It does not expose protected APIs or suppress archived-stack recovery. No schema migration or agent permission change is required. ## Model Used OpenAI GPT-6 through Codex. Used reasoning, repository tools, test execution, and browser control. The runtime does not expose a more specific model ID or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/openai-dot-runner.md | 22 ++++- packages/shared/src/dot-invitations.ts | 4 + server/src/__tests__/dot-invitations.test.ts | 28 ++++++ server/src/routes/dot-runner.ts | 10 ++- ui/src/api/client.test.ts | 18 ++++ ui/src/components/NewAgentDialog.test.tsx | 86 +++++++++++++++++-- .../new-agent/ExternalAgentInviteContent.tsx | 16 ++-- .../new-agent/ExternalAgentInviteDialog.tsx | 16 ++-- ui/src/lib/company-routes.test.ts | 5 ++ ui/src/lib/company-routes.ts | 2 +- ui/src/lib/tenant-session-recovery.test.ts | 30 +++++++ ui/src/lib/tenant-session-recovery.ts | 12 ++- .../ExternalAgentInvitePreview.tsx | 2 +- .../journeys.stories.tsx | 11 +++ 14 files changed, 236 insertions(+), 26 deletions(-) diff --git a/doc/openai-dot-runner.md b/doc/openai-dot-runner.md index 8d2c911b609..de72ca7c4fc 100644 --- a/doc/openai-dot-runner.md +++ b/doc/openai-dot-runner.md @@ -6,9 +6,10 @@ owns the assignment lifecycle and durable tool receipts. Dot uses Paperclip's existing agent permissions and task tools, including document writes and completion feedback. -This first version supports a self-hosted instance with a local Runner -controller and a stable public HTTPS origin. Hosted agent-broker and remote -controller deployments are not qualified. The feature is off by default. +Dot supports a self-hosted instance with a local Runner controller and a stable +public HTTPS origin, or a Cloud instance with a managed sandbox Runner and the +tenant Dot ingress. The feature is off by default. Cloud requires the native +Runner setting in addition to Dot and Assistant connections (MCP). ## Enable and pair @@ -418,6 +419,12 @@ The setup prompt asks Dot to upload its own current image only if it can obtain ### Invite from the agent picker +A new Dot remains in hire preparation until pairing and the event check finish. +Setup can issue its prompt during preparation and verification; the compatibility +status `paused` does not require an operator resume in these states. The live +checks continue until the binding and agent lifecycle are both ready. A manual +pause or pending hire approval still blocks setup. + Enable **OpenAI Dot** and **Assistant connections (MCP)**, then choose **New Agent → Invite an external agent → Dot**. Copy the setup prompt into your Dot. Paperclip creates a scoped Runner agent and watches connection, event subscription, and a harmless event round trip. If your company requires hire approval, approve the agent before copying its pairing prompt. The test event is sent automatically after the callback is verified; Retry test event remains available if confirmation times out. Reopening setup resumes the operator's unfinished invitation. Pairing codes are not stored in browser persistence. After refreshing or when an open prompt expires, setup automatically prepares a fresh prompt to copy. It first checks current connection state and replaces only the pending capability, without revoking an established connection. A failed renewal offers a retry rather than looping. If another browser window replaces the prompt, the current window asks before replacing it again. Hermes and Other continue to use the ordinary external-agent invitation flow. @@ -447,3 +454,12 @@ pnpm exec vitest run server/src/__tests__/dot-runner.test.ts -t 'private Daytona ``` The fixture creates a private, bounded sandbox and deletes it in `finally`. Default tests do not contact Daytona. This test uses a scripted MCP client; a separate real OpenAI Dot walkthrough is required to qualify provider behavior. + +### Public Cloud consent + +The Dot consent page is intentionally accessible without a Paperclip board +session. Background UI probes may receive `tenant_session_required` or +`tenant_session_invalid` there; they must not reload the public consent document. +The public request and pairing endpoints still enforce the OAuth request and +one-use pairing code. Normal board routes retain Cloud session recovery, and +archived stacks still redirect through a document reload. diff --git a/packages/shared/src/dot-invitations.ts b/packages/shared/src/dot-invitations.ts index 469eb5df94c..88cd4e0dc64 100644 --- a/packages/shared/src/dot-invitations.ts +++ b/packages/shared/src/dot-invitations.ts @@ -1,3 +1,5 @@ +import type { AgentLifecycleState } from "./types/agent-lifecycle.js"; + /** JSON responses for the operator-owned Dot invitation flow. Pairing capabilities are transient. */ export interface DotBinding { id: string; @@ -17,6 +19,8 @@ export interface DotConnection { enabled: boolean; resourceUrl: string | null; agentStatus: string; + agentLifecycleState: AgentLifecycleState; + canConfigureConnection: boolean; binding: DotBinding | null; } export interface DotPairing { bindingId: string; pairingCode: string; expiresAt: string } diff --git a/server/src/__tests__/dot-invitations.test.ts b/server/src/__tests__/dot-invitations.test.ts index 84fd5d102c2..ed735f6dd66 100644 --- a/server/src/__tests__/dot-invitations.test.ts +++ b/server/src/__tests__/dot-invitations.test.ts @@ -128,3 +128,31 @@ it("does not trust editable agent metadata as an invitation ownership receipt", expect(await service.resume(f.companyId, f.userId)).toBeNull(); expect((await service.create(f.companyId, f.userId)).agent.id).not.toBe(spoof!.id); }); + +it("reports connection setup authority separately from compatibility status", async () => { + const f = await fixture(); + const invite = await dotInvitationService(db).create(f.companyId, f.userId); + const app = express(); + app.use((req, _res, next) => { + req.actor = { type: "board", source: "session", userId: f.userId, companyIds: [f.companyId], + memberships: [{ companyId: f.companyId, membershipRole: "owner", status: "active" }] }; + next(); + }); + app.use(dotRunnerRoutes(db, "https://paperclip.example/mcp/runner")); + const path = `/companies/${f.companyId}/agents/${invite.agent.id}/dot-binding`; + const cases = [ + { lifecycleState: "preparing", status: "paused", allowed: true }, + { lifecycleState: "verifying", status: "paused", allowed: true }, + { lifecycleState: "ready", status: "idle", allowed: true }, + { lifecycleState: "paused", status: "paused", allowed: false }, + { lifecycleState: "pending_approval", status: "pending_approval", allowed: false }, + { lifecycleState: "terminated", status: "terminated", allowed: false }, + ] as const; + for (const state of cases) { + await db.update(agents).set({ lifecycleState: state.lifecycleState, status: state.status }).where(eq(agents.id, invite.agent.id)); + const result = await request(app).get(path); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ agentStatus: state.status, agentLifecycleState: state.lifecycleState, + canConfigureConnection: state.allowed }); + } +}); diff --git a/server/src/routes/dot-runner.ts b/server/src/routes/dot-runner.ts index 928806548c1..8bf06bcc92f 100644 --- a/server/src/routes/dot-runner.ts +++ b/server/src/routes/dot-runner.ts @@ -9,6 +9,7 @@ import { agentService } from "../services/agents.js"; import { notFound } from "../errors.js"; import { accessService } from "../services/access.js"; import { dotRunnerBroker } from "../services/dot-runner-broker.js"; +import { canConfigureAgentConnection } from "../modules/agent-lifecycle/index.js"; export function dotRunnerRoutes(db: Db, resourceUrl?: string) { const router = Router(); @@ -48,7 +49,14 @@ export function dotRunnerRoutes(db: Db, resourceUrl?: string) { const agent = await agentService(db).getById(input.agentId); if (!agent || agent.companyId !== input.companyId) throw notFound("Agent not found"); const config = resourceUrl ? null : publicMcpConfig(process.env); - res.json({ agentStatus: agent.status, enabled: await broker.enabled(), resourceUrl: resourceUrl ?? (config ? config.origin + "/mcp/runner" : null), binding: await broker.bindingForAgent(input.companyId, input.agentId) }); + res.json({ + agentStatus: agent.status, + agentLifecycleState: agent.lifecycleState, + canConfigureConnection: canConfigureAgentConnection(agent), + enabled: await broker.enabled(), + resourceUrl: resourceUrl ?? (config ? config.origin + "/mcp/runner" : null), + binding: await broker.bindingForAgent(input.companyId, input.agentId), + }); }); router.post(path, async (req, res) => { const input = scope(req, true); diff --git a/ui/src/api/client.test.ts b/ui/src/api/client.test.ts index 4b69efa9192..0cac5237006 100644 --- a/ui/src/api/client.test.ts +++ b/ui/src/api/client.test.ts @@ -43,6 +43,24 @@ afterEach(() => { }); describe("tenant-session recovery", () => { + it("loads anonymous Dot consent despite concurrent protected background probes", async () => { + const reload = vi.fn(); + const requestId = `pcmcp_request_${"a".repeat(43)}`; + const recovery = createTenantSessionRecoveryCoordinator(reload, () => `/dot-connect/${requestId}`); + vi.spyOn(tenantSessionRecovery, "recoverIfNeeded").mockImplementation(recovery.recoverIfNeeded); + fetchMock.mockImplementation((url: string) => Promise.resolve( + url.includes("/dot-mcp/requests/") + ? jsonResponse({ id: requestId, agentConnection: true }) + : errorResponse({ error: "tenant_session_required" }), + )); + + const background = api.get("/health"); + const consent = api.get(`/dot-mcp/requests/${requestId}`); + await expect(background).rejects.toMatchObject({ status: 401 }); + await expect(consent).resolves.toEqual({ id: requestId, agentConnection: true }); + expect(reload).not.toHaveBeenCalled(); + }); + it("keeps concurrent failures pending and schedules one top-level reload", async () => { const reload = vi.fn(); const recovery = createTenantSessionRecoveryCoordinator(reload); diff --git a/ui/src/components/NewAgentDialog.test.tsx b/ui/src/components/NewAgentDialog.test.tsx index 66a141d5786..0b0dbe2953d 100644 --- a/ui/src/components/NewAgentDialog.test.tsx +++ b/ui/src/components/NewAgentDialog.test.tsx @@ -221,7 +221,7 @@ it.each([{ enableOpenAiDot: false, enablePublicMcp: true }, { enableOpenAiDot: t }); it("copies one scoped Dot prompt, resumes on Back, and trusts only server readiness", async () => { - const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", binding: null as any }; + const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: null as any }; dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: null }); dotApi.connection.mockImplementation(async () => ({ ...connection })); dotApi.pair.mockImplementation(async () => { @@ -265,7 +265,7 @@ it("copies one scoped Dot prompt, resumes on Back, and trusts only server readin it("waits for company approval before issuing a Dot capability", async () => { dotApi.create.mockResolvedValue({ agent: { id: "pending-dot", status: "pending_approval" }, approvalId: "approval-id", binding: null }); - dotApi.connection.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "pending_approval", binding: null }); + dotApi.connection.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "pending_approval", agentLifecycleState: "pending_approval", canConfigureConnection: false, binding: null }); await act(async () => cache.setQueryData(queryKeys.instance.experimentalSettings, { enableOpenAiDot: true, enablePublicMcp: true })); await click("Invite an external agent"); await act(async () => [...document.querySelectorAll("button")].find(b => b.textContent?.startsWith("Dot"))!.click()); @@ -298,7 +298,7 @@ const pendingDotBinding = (id: string, expiresAt: string) => ({ }); it.each(["expired", "unavailable"])("automatically replaces an %s saved Dot prompt on opening", async condition => { - const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", + const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: pendingDotBinding("saved-binding", new Date(Date.now() + (condition === "expired" ? -60000 : 900000)).toISOString()) }; dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: connection.binding }); dotApi.connection.mockImplementation(async () => ({ ...connection })); @@ -318,7 +318,7 @@ it.each(["expired", "unavailable"])("automatically replaces an %s saved Dot prom }); it("renews a Dot prompt that expires while setup stays open", async () => { - const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", binding: null as any }; + const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: null as any }; dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: null }); dotApi.connection.mockImplementation(async () => ({ ...connection })); let generation = 0; @@ -342,10 +342,10 @@ it("renews a Dot prompt that expires while setup stays open", async () => { it("checks fresh connection state before renewing a cached pending Dot invitation", async () => { const staleBinding = pendingDotBinding("saved-binding", new Date(Date.now() - 60000).toISOString()); cache.setQueryData(["dot-binding", "company-1", "dot-agent"], { - enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", binding: staleBinding, + enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: staleBinding, }); dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: staleBinding }); - dotApi.connection.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", + dotApi.connection.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: { ...staleBinding, status: "ready", connected: true, subscriptionVerified: true } }); await openDotSetup(); expect(dotApi.connection).toHaveBeenCalled(); @@ -354,7 +354,7 @@ it("checks fresh connection state before renewing a cached pending Dot invitatio }); it("offers a retry after automatic renewal fails without rotating on every poll", async () => { - const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", + const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: pendingDotBinding("saved-binding", new Date(Date.now() - 60000).toISOString()) }; dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: connection.binding }); dotApi.connection.mockImplementation(async () => ({ ...connection })); @@ -378,7 +378,7 @@ it("offers a retry after automatic renewal fails without rotating on every poll" it("does not let an older window renew another window's fresh Dot prompt", async () => { - const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", binding: null as any }; + const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: null as any }; dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: null }); dotApi.connection.mockImplementation(async () => ({ ...connection })); dotApi.pair.mockImplementation(async () => { @@ -396,7 +396,7 @@ it("does not let an older window renew another window's fresh Dot prompt", async }); it("retries the event test after Dot connects during a failed prompt renewal", async () => { - const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", + const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: pendingDotBinding("saved-binding", new Date(Date.now() - 60000).toISOString()) as any }; dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: connection.binding }); dotApi.connection.mockImplementation(async () => ({ ...connection })); @@ -411,3 +411,71 @@ it("retries the event test after Dot connects during a failed prompt renewal", a expect(dotApi.retry).toHaveBeenCalledExactlyOnceWith("company-1", "dot-agent", "saved-binding"); expect(dotApi.pair).toHaveBeenCalledTimes(1); }); + + +it.each(["preparing", "verifying"])("prepares the Dot prompt while the hire is %s", async agentLifecycleState => { + const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "paused", + agentLifecycleState, canConfigureConnection: true, binding: null as any }; + dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "paused" }, approvalId: null, binding: null }); + dotApi.connection.mockImplementation(async () => ({ ...connection })); + dotApi.pair.mockImplementation(async () => { + connection.binding = pendingDotBinding("binding", new Date(Date.now() + 900000).toISOString()); + return { bindingId: "binding", pairingCode: "setup-hire-code", expiresAt: connection.binding.pairingExpiresAt }; + }); + await openDotSetup(); + expect(dotApi.pair).toHaveBeenCalledTimes(1); + expect(document.body.textContent).not.toContain("Resume this agent"); + await click("Copy setup prompt"); + expect(invites.copy.mock.calls[0][0]).toContain("setup-hire-code"); +}); + +it("does not prepare a Dot capability for a real paused agent", async () => { + dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "paused" }, approvalId: null, binding: null }); + dotApi.connection.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", + agentStatus: "paused", agentLifecycleState: "paused", canConfigureConnection: false, binding: null }); + await openDotSetup(); + expect(dotApi.pair).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Resume this agent before connecting Dot."); +}); + +it("keeps watching after the event check until hire verification completes", async () => { + const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "paused", + agentLifecycleState: "verifying", canConfigureConnection: true, binding: null as any }; + dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "paused" }, approvalId: null, binding: null }); + dotApi.connection.mockImplementation(async () => ({ ...connection })); + dotApi.pair.mockImplementation(async () => { + connection.binding = pendingDotBinding("binding", new Date(Date.now() + 900000).toISOString()); + return { bindingId: "binding", pairingCode: "setup-hire-code", expiresAt: connection.binding.pairingExpiresAt }; + }); + await openDotSetup(); + connection.binding = { ...connection.binding, status: "ready", connected: true, subscriptionVerified: true, hasPendingChallenge: false }; + await act(async () => cache.invalidateQueries({ queryKey: ["dot-binding", "company-1", "dot-agent"] })); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(document.body.textContent).not.toContain("Your Dot is connected"); + expect(document.body.textContent).toContain("Test event confirmed: complete"); + expect(document.body.textContent).toContain("Your Dot confirmed the test event. Paperclip is finishing agent setup."); + const finishing = Array.from(document.querySelectorAll("button")).find(button => button.textContent?.includes("Finishing setup…")); + expect(finishing?.disabled).toBe(true); + connection.agentLifecycleState = "ready"; + connection.agentStatus = "idle"; + await act(async () => { await new Promise(resolve => setTimeout(resolve, 2750)); }); + expect(document.body.textContent).toContain("Your Dot is connected"); + await click("Done"); + expect(state.close).toHaveBeenCalledTimes(1); +}); + +it("keeps completed checks but shows a pause blocker for a paired paused Dot", async () => { + dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "paused" }, approvalId: null, binding: null }); + dotApi.connection.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", + agentStatus: "paused", agentLifecycleState: "paused", canConfigureConnection: false, + binding: { ...pendingDotBinding("binding", new Date(Date.now() + 900000).toISOString()), + status: "ready", connected: true, subscriptionVerified: true, hasPendingChallenge: false } }); + await openDotSetup(); + expect(document.body.textContent).toContain("Test event confirmed: complete"); + expect(document.body.textContent).toContain("Resume this agent before connecting Dot."); + expect(document.body.textContent).not.toContain("Finishing setup…"); + expect(document.body.textContent).not.toContain("Paperclip is finishing agent setup."); + const blocked = Array.from(document.querySelectorAll("button")).find(button => button.textContent?.includes("Agent unavailable")); + expect(blocked?.disabled).toBe(true); + expect(dotApi.pair).not.toHaveBeenCalled(); +}); diff --git a/ui/src/components/new-agent/ExternalAgentInviteContent.tsx b/ui/src/components/new-agent/ExternalAgentInviteContent.tsx index 401ce03098c..c8774390bfb 100644 --- a/ui/src/components/new-agent/ExternalAgentInviteContent.tsx +++ b/ui/src/components/new-agent/ExternalAgentInviteContent.tsx @@ -8,8 +8,8 @@ import { cn } from "@/lib/utils"; export type ExternalAgentPreset = "dot" | "hermes" | "other"; export type DotConnectionState = { - phase: "waiting" | "connected" | "subscribed" | "testing" | "ready"; - problem?: "event_timeout" | "prompt_unavailable" | "offline"; + phase: "waiting" | "connected" | "subscribed" | "testing" | "finishing" | "ready"; + problem?: "event_timeout" | "prompt_unavailable" | "offline" | "agent_unavailable"; }; const presets = [ @@ -43,10 +43,12 @@ const checks = [ /** Controlled by server evidence in the invite controller, never by copying a prompt. */ export function DotConnectionChecks({ state }: { state: DotConnectionState }) { - const completed = { waiting: 0, connected: 1, subscribed: 2, testing: 2, ready: 3 }[state.phase]; - const message = state.problem === "offline" ? "Connection updates paused. Reconnect to check the latest status." + const completed = { waiting: 0, connected: 1, subscribed: 2, testing: 2, finishing: 3, ready: 3 }[state.phase]; + const message = state.problem === "agent_unavailable" ? "Connection checks passed, but this agent cannot receive tasks. Resolve the agent’s setup blocker in Paperclip." + : state.problem === "offline" ? "Connection updates paused. Reconnect to check the latest status." : state.problem === "prompt_unavailable" ? "This setup prompt was replaced in another window. Create a fresh prompt to continue." : state.problem === "event_timeout" ? "Your Dot connected, but hasn’t confirmed the test event. Ask it to check Paperclip, then retry." + : state.phase === "finishing" ? "Your Dot confirmed the test event. Paperclip is finishing agent setup." : state.phase === "ready" ? "Your Dot is ready for tasks. Messages can travel both ways." : state.phase === "waiting" ? "Watching for your Dot. Updates will appear here automatically." : state.phase === "connected" ? "Your Dot connected. Waiting for it to enable task updates." @@ -99,8 +101,10 @@ export function ExternalAgentInviteContent({ const dot = preset === "dot"; const ready = dot && connection.phase === "ready" && !connection.problem; const connecting = dot && connection.phase !== "waiting"; - const progressLabel = connection.problem === "offline" ? "Updates paused" + const progressLabel = connection.problem === "agent_unavailable" ? "Agent unavailable" + : connection.problem === "offline" ? "Updates paused" : connection.problem === "event_timeout" ? "Needs attention" + : connection.phase === "finishing" ? "Finishing setup…" : connection.phase === "connected" ? "Connecting…" : "Confirming connection…"; return <>
@@ -113,6 +117,8 @@ export function ExternalAgentInviteContent({ {!provider ? `Bring an agent you already use into ${companyName}.` : ready ? `Your Dot can now receive assignments and work with ${companyName}.` + : connection.problem === "agent_unavailable" ? "Your Dot is connected. This agent must be available in Paperclip before it can receive assignments." + : connection.phase === "finishing" ? "Your Dot has connected and confirmed task updates. Paperclip is preparing it for assignments." : connecting ? "Your Dot has connected. We’re checking that task updates can travel both ways." : dot ? "Copy the setup prompt and send it to your Dot in ChatGPT. Your Dot will connect itself; we’ll watch for it here." : `Copy the invitation prompt and send it to your ${preset === "hermes" ? "Hermes " : ""}agent. Approve its join request in Paperclip when it’s ready.`} diff --git a/ui/src/components/new-agent/ExternalAgentInviteDialog.tsx b/ui/src/components/new-agent/ExternalAgentInviteDialog.tsx index 4a511c56aa7..f49a61a24e8 100644 --- a/ui/src/components/new-agent/ExternalAgentInviteDialog.tsx +++ b/ui/src/components/new-agent/ExternalAgentInviteDialog.tsx @@ -36,7 +36,8 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: { enabled: preset === "dot" && !!invitation, retry: false, staleTime: 0, - refetchInterval: query => query.state.error ? false : query.state.data?.binding?.status === "ready" && query.state.data.binding.subscriptionVerified ? false : 2500, + refetchInterval: query => query.state.error ? false : query.state.data?.agentLifecycleState === "ready" && query.state.data.canConfigureConnection + && query.state.data.binding?.status === "ready" && query.state.data.binding.subscriptionVerified ? false : 2500, }); const generate = useMutation({ mutationFn: async (kind: ExternalAgentPreset) => { @@ -71,7 +72,7 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: { onSuccess: () => { void cache.invalidateQueries({ queryKey: key }); } }); const binding = state.data?.binding; const canPreparePairing = preset === "dot" && state.data?.enabled && !!state.data.resourceUrl - && !["pending_approval", "paused", "terminated"].includes(state.data.agentStatus) + && state.data.canConfigureConnection && (!binding || binding.status === "pairing"); const preparePairing = canPreparePairing && !pairing && !state.error && !pair.isError && !attemptedAutomaticPairing.current @@ -93,15 +94,20 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: { }, Math.max(0, Date.parse(pairing.expiresAt) - Date.now())); return () => window.clearTimeout(timer); }, [pairing]); + const checksReady = binding?.status === "ready" && binding.subscriptionVerified; + const agentReady = state.data?.agentLifecycleState === "ready" && state.data.canConfigureConnection; + const finishingSetup = ["preparing", "verifying"].includes(state.data?.agentLifecycleState ?? "") + && state.data?.canConfigureConnection; const connection: DotConnectionState = { - phase: binding?.status === "ready" && binding.subscriptionVerified && !["paused", "terminated", "pending_approval"].includes(state.data?.agentStatus ?? "") ? "ready" + phase: checksReady ? agentReady ? "ready" : finishingSetup ? "finishing" : "ready" : binding?.hasPendingChallenge ? "testing" : binding?.subscriptionVerified ? "subscribed" : binding?.connected ? "connected" : "waiting", problem: state.error ? "offline" + : checksReady && !agentReady && !finishingSetup ? "agent_unavailable" : binding?.status === "pairing" && !preparePairing && !pair.isPending && !state.isFetching && !pair.isError && pairing?.bindingId !== binding.id ? "prompt_unavailable" : binding?.challengeExpiresAt && !binding.hasPendingChallenge && binding.status !== "ready" ? "event_timeout" : undefined, }; const pendingApproval = (state.data?.agentStatus ?? invitation?.agent.status) === "pending_approval"; - const unavailable = state.data && ["paused", "terminated"].includes(state.data.agentStatus); + const unavailable = state.data && !state.data.canConfigureConnection && !pendingApproval; const prompt = preset === "dot" ? pairing && pairing.bindingId === binding?.id && state.data?.resourceUrl && invitation ? buildDotSetupPrompt({ companyId, agentId: invitation.agent.id, resourceUrl: state.data.resourceUrl, ...pairing }) : "" : genericPrompt; const error = (generate.variables === preset ? generate.error : null) ?? (preset === "dot" ? (binding?.connected ? null : pair.error) ?? test.error : null); @@ -118,7 +124,7 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: { { setPreset(kind); if (kind === "dot" ? !invitation : !genericPrompt) generate.mutate(kind); }} onBack={() => setPreset(null)} onClose={preset ? onClose : onBack} onCopied={() => { if (preset === "dot") void state.refetch(); }} diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts index f478aaf593b..3ba37e19d66 100644 --- a/ui/src/lib/company-routes.test.ts +++ b/ui/src/lib/company-routes.test.ts @@ -7,6 +7,11 @@ import { } from "./company-routes"; describe("company routes", () => { + it("keeps public Dot consent outside company-prefixed routes", () => { + const path = `/dot-connect/pcmcp_request_${"a".repeat(43)}`; + expect(extractCompanyPrefixFromPath(path)).toBeNull(); + expect(applyCompanyPrefix(path, "DOT")).toBe(path); + }); it("treats the task-list alias as an unprefixed board route", () => { expect(isBoardPathWithoutPrefix("/tasks")).toBe(true); expect(extractCompanyPrefixFromPath("/tasks")).toBeNull(); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index e42b1e7f15f..93152e2c914 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -33,7 +33,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "timeline", ]); -const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "cli-auth", "mcp-connect", "assistant-connections", "docs", "instance"]); +const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "cli-auth", "mcp-connect", "dot-connect", "assistant-connections", "docs", "instance"]); export function normalizeCompanyPrefix(prefix: string): string { return prefix.trim().toUpperCase(); diff --git a/ui/src/lib/tenant-session-recovery.test.ts b/ui/src/lib/tenant-session-recovery.test.ts index 7384f2cc53d..85a0c0bb08d 100644 --- a/ui/src/lib/tenant-session-recovery.test.ts +++ b/ui/src/lib/tenant-session-recovery.test.ts @@ -43,6 +43,36 @@ describe("isArchivedStackRecoveryError", () => { }); describe("tenant-session recovery coordinator", () => { + it.each(["tenant_session_required", "tenant_session_invalid"])( + "leaves public Dot consent mounted after an expected %s background failure", + (error) => { + const reload = vi.fn(); + const pathname = `/dot-connect/pcmcp_request_${"a".repeat(43)}`; + const recovery = createTenantSessionRecoveryCoordinator(reload, () => pathname); + + expect(recovery.recoverIfNeeded(401, { error })).toBeNull(); + expect(reload).not.toHaveBeenCalled(); + }, + ); + + it.each(["/DOT/agents/dot", "/dot-connect/not-a-request", `/dot-connect/pcmcp_request_${"a".repeat(43)}/extra`])( + "still recovers tenant sessions outside the public consent route: %s", + (pathname) => { + const reload = vi.fn(); + const recovery = createTenantSessionRecoveryCoordinator(reload, () => pathname); + + expect(recovery.recoverIfNeeded(401, { error: "tenant_session_required" })).not.toBeNull(); + expect(reload).toHaveBeenCalledTimes(1); + }, + ); + + it("still leaves archived stacks from the public Dot consent route", () => { + const reload = vi.fn(); + const recovery = createTenantSessionRecoveryCoordinator(reload, () => `/dot-connect/pcmcp_request_${"a".repeat(43)}`); + + expect(recovery.recoverIfNeeded(423, { statusPage: { code: "archived" } })).not.toBeNull(); + expect(reload).toHaveBeenCalledTimes(1); + }); it("reloads once and shares one never-settling promise across concurrent failures", async () => { const reload = vi.fn(); const recovery = createTenantSessionRecoveryCoordinator(reload); diff --git a/ui/src/lib/tenant-session-recovery.ts b/ui/src/lib/tenant-session-recovery.ts index 210839bfb9e..45d9df3628d 100644 --- a/ui/src/lib/tenant-session-recovery.ts +++ b/ui/src/lib/tenant-session-recovery.ts @@ -40,12 +40,22 @@ export interface TenantSessionRecoveryCoordinator { export function createTenantSessionRecoveryCoordinator( reloadTopLevelPage: () => void, + getDocumentPathname: () => string = () => "", ): TenantSessionRecoveryCoordinator { let recoveryPromise: Promise | null = null; return { recoverIfNeeded(status, body) { if (!isTenantDocumentRecoveryError(status, body)) return null; + // Dot consent is intentionally public. Global settings/session probes can + // receive an expected 401 here; reloading would repeat that probe forever + // and prevent the public connection request from finishing. Leave those + // failures to their consumers while preserving recovery on board routes + // and for archived stacks. + if ( + isTenantSessionRecoveryError(status, body) + && /^\/dot-connect\/pcmcp_request_[A-Za-z0-9_-]{43}$/.test(getDocumentPathname()) + ) return null; if (recoveryPromise) return recoveryPromise; // Keep every affected consumer pending while the browser leaves this @@ -70,4 +80,4 @@ export const tenantSessionRecovery = createTenantSessionRecoveryCoordinator(() = // stack is redirected to the portfolio. const topLevelWindow = window.top ?? window; topLevelWindow.location.reload(); -}); +}, () => typeof window === "undefined" ? "" : window.location.pathname); diff --git a/ui/storybook/stories/external-agent-invite/ExternalAgentInvitePreview.tsx b/ui/storybook/stories/external-agent-invite/ExternalAgentInvitePreview.tsx index d727cdb12de..db23a4c5f2c 100644 --- a/ui/storybook/stories/external-agent-invite/ExternalAgentInvitePreview.tsx +++ b/ui/storybook/stories/external-agent-invite/ExternalAgentInvitePreview.tsx @@ -36,7 +36,7 @@ export function ExternalAgentInvitePreview({ useEffect(() => { if (!simulate || !watching || connection.problem || connection.phase === "ready") return; const phase = connection.phase; - const next = { waiting: "connected", connected: "subscribed", subscribed: "testing", testing: "ready" } as const; + const next = { waiting: "connected", connected: "subscribed", subscribed: "testing", testing: "finishing", finishing: "ready" } as const; const timer = window.setTimeout(() => { setConnection(phase === "testing" && failTest && !retrying ? { phase, problem: "event_timeout" } : { phase: next[phase] }); diff --git a/ui/storybook/stories/external-agent-invite/journeys.stories.tsx b/ui/storybook/stories/external-agent-invite/journeys.stories.tsx index 7ba8b403bd7..0c002c46722 100644 --- a/ui/storybook/stories/external-agent-invite/journeys.stories.tsx +++ b/ui/storybook/stories/external-agent-invite/journeys.stories.tsx @@ -20,7 +20,18 @@ export const ChooseAgent: Story = { name: "Choose · Dot, Hermes, or Other", arg export const GivePromptToDot: Story = { name: "Dot · Copy and send the prompt", args: { initialScreen: "setup" } }; export const WatchingDotConnect: Story = { name: "Dot · Watching task updates", args: { initialScreen: "setup", initialConnection: { phase: "connected" }, simulate: false } }; export const ConfirmingRoundTrip: Story = { name: "Dot · Confirming the round trip", args: { initialScreen: "setup", initialConnection: { phase: "testing" }, simulate: false } }; +export const FinishingAgentSetup: Story = { + name: "Dot · Finishing agent setup", + args: { initialScreen: "setup", initialConnection: { phase: "finishing" }, simulate: false }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await expect(page.getByText("Test event confirmed", { exact: false })).toHaveTextContent("complete"); + await expect(page.getByRole("button", { name: "Finishing setup…" })).toBeDisabled(); + await expect(page.queryByRole("button", { name: "Done" })).not.toBeInTheDocument(); + }, +}; export const DotReady: Story = { name: "Dot · Ready for tasks", args: { initialScreen: "setup", initialConnection: { phase: "ready" } } }; +export const PausedAgent: Story = { name: "Recovery · Connected agent paused", args: { initialScreen: "setup", initialConnection: { phase: "ready", problem: "agent_unavailable" }, simulate: false } }; export const RetryEvent: Story = { name: "Recovery · Retry the test event", args: { initialScreen: "setup", initialConnection: { phase: "testing", problem: "event_timeout" } } }; export const RefreshingPrompt: Story = { name: "Setup · Preparing a fresh prompt automatically", args: { initialScreen: "setup", preparing: true, simulate: false } }; export const WatchingInterrupted: Story = { name: "Recovery · Connection updates interrupted", args: { initialScreen: "setup", initialConnection: { phase: "connected", problem: "offline" } } };