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 <>