Fix Dot Cloud invitation and anonymous consent setup (#15722)

## 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-09 15:19:33 -05:00
1 parent 65965f0d3a
commit cb291bf129
14 files changed
+236 -26

No files matched your search

+19 -3
View File
@@ -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.
+4
View File
@@ -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 }
@@ -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 });
}
});
+9 -1
View File
@@ -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);
+18
View File
@@ -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);
+77 -9
View File
@@ -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();
});
@@ -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 <>
<div className="min-h-0 space-y-6 overflow-y-auto px-6 pb-6 pt-8 sm:px-8">
@@ -113,6 +117,8 @@ export function ExternalAgentInviteContent({
<DialogDescription className="text-sm leading-relaxed">
{!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.`}
@@ -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 }: {
<AnimatedDialogContent className="sm:max-w-(--sz-560px)">
<ExternalAgentInviteContent preset={preset} prompt={prompt} companyName={selectedCompany?.name ?? "your organization"}
connection={connection} dotDisabledReason={dotDisabledReason} busy={busy}
error={error?.message ?? (unavailable ? "Resume this agent before connecting Dot." : undefined)}
error={error?.message ?? (unavailable ? state.data?.agentLifecycleState === "terminated" ? "This agent was terminated. Invite another agent to connect Dot." : "Resume this agent before connecting Dot." : undefined)}
approvalHref={pendingApproval && invitation?.approvalId ? `/approvals/${invitation.approvalId}` : undefined}
onSelect={kind => { setPreset(kind); if (kind === "dot" ? !invitation : !genericPrompt) generate.mutate(kind); }}
onBack={() => setPreset(null)} onClose={preset ? onClose : onBack} onCopied={() => { if (preset === "dot") void state.refetch(); }}
+5
View File
@@ -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();
+1 -1
View File
@@ -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();
@@ -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);
+11 -1
View File
@@ -40,12 +40,22 @@ export interface TenantSessionRecoveryCoordinator {
export function createTenantSessionRecoveryCoordinator(
reloadTopLevelPage: () => void,
getDocumentPathname: () => string = () => "",
): TenantSessionRecoveryCoordinator {
let recoveryPromise: Promise<never> | 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);
@@ -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] });
@@ -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" } } };