diff --git a/doc/plans/2026-09-30-paperclip-public-mcp-and-plugins.md b/doc/plans/2026-09-30-paperclip-public-mcp-and-plugins.md index 4d2c7e26401..bf91ab436cf 100644 --- a/doc/plans/2026-09-30-paperclip-public-mcp-and-plugins.md +++ b/doc/plans/2026-09-30-paperclip-public-mcp-and-plugins.md @@ -363,7 +363,7 @@ and retrieve results later. [The runbook](../public-mcp.md) includes commands. The shared production Paperclip skill also gains generic document-creation and read-back guidance. -Hosted use additionally requires Cloud migration 0054, a secret-store encryption +Hosted use additionally requires Cloud migration 0055, a secret-store encryption key, the Cloud feature flag, and a compatible tenant release/fleet rollout. Signup eligibility, execution credentials and spending setup remain explicit. Merging does not create a company, launch paid agents, expose a live public diff --git a/server/src/__tests__/public-mcp.test.ts b/server/src/__tests__/public-mcp.test.ts index 87dc3167fde..eebd3245128 100644 --- a/server/src/__tests__/public-mcp.test.ts +++ b/server/src/__tests__/public-mcp.test.ts @@ -446,6 +446,41 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () => } }); + it("bounds lifetimes and retries without claiming unsupported replay", async () => { + const f = await eventFixture(); + const short = await f.service.subscribe(f.principal, { ...f.input, ttlMs: 1 }); + expect(Date.parse(short.refreshBefore)).toBe(f.now() + 30_000); + const unlimited = await f.service.subscribe(f.principal, { ...f.input, ttlMs: null }); + expect(Date.parse(unlimited.refreshBefore)).toBe(f.now() + 24 * 3600_000); + expect(unlimited.cursor).toBeNull(); + await expect(f.service.subscribe(f.principal, { ...f.input, cursor: "pretend-replay" })).rejects.toThrow(); + await f.activity(); f.setStatus(503); + for (let attempt = 0; attempt < 8; attempt++) { await f.service.tick(); f.advance(3600_000); } + expect(f.received.filter(r => r.body.eventId)).toHaveLength(6); + expect(new Set(f.received.filter(r => r.body.eventId).map(r => r.body.eventId)).size).toBe(1); + await f.service.unsubscribe(f.principal, f.input); + }); + + it("does not skip an older activity that commits after a newer event was delivered", async () => { + const f = await eventFixture(); await f.service.subscribe(f.principal, f.input); + let release!: () => void; + let inserted!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const ready = new Promise(resolve => { inserted = resolve; }); + const lateId = randomUUID(); + const transaction = db.transaction(async tx => { + await tx.insert(activityLog).values({ id: lateId, companyId: f.company.id, actorType: "user", actorId: f.actor.userId!, action: "issue.updated", entityType: "issue", entityId: f.task.id, details: { status: "done" }, createdAt: new Date(f.now() + 1) }); + inserted(); await gate; + }); + try { + await ready; const newer = await f.activity(); await f.service.tick(); + expect(f.received.filter(r => r.body.eventId).map(r => r.body.eventId)).toEqual(["evt_" + newer.id]); + } finally { release(); await transaction; } + await f.service.tick(); + expect(f.received.filter(r => r.body.eventId).at(-1)!.body.eventId).toBe("evt_" + lateId); + await f.service.unsubscribe(f.principal, f.input); + }); + it("sends only comment/document references and checks hosted membership on each delivery", async () => { const f = await eventFixture("https://cloud.example"); const authority = { token: "fixture-cloud-proof", expiresAt: f.now() + 60_000 };