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" } } };