From 754a4e792e077999989b088a910ce9d846280fda Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 16:18:01 -0500 Subject: [PATCH] fix(computers): authorize scoped cleanup of previous viewers Preserve the full route regression suite in the stacked acceptance proof branch. Cleanup remains fenced by company, environment, current user, and exact owner generation. Co-Authored-By: Paperclip --- .../computers/application/service.test.ts | 24 +++++++++++++++++++ server/src/routes/computers.ts | 14 +++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/server/src/modules/computers/application/service.test.ts b/server/src/modules/computers/application/service.test.ts index d7211d9a7ba..a576befd684 100644 --- a/server/src/modules/computers/application/service.test.ts +++ b/server/src/modules/computers/application/service.test.ts @@ -298,6 +298,30 @@ describe("computer ownership", () => { await f.service.reconcile(); expect(f.backend.stop).toHaveBeenCalledOnce(); }); + it("disconnects only the exact company-scoped viewer owned by the current user without provider access", async () => { + const f = fixture(); + await f.attach(); + const viewer = await f.service.connect({ ...f.scope, userId: "alice", idleTimeoutMs: 60_000 }); + const other = await f.service.connect({ ...f.scope, userId: "bob", idleTimeoutMs: 60_000 }); + const runner = await f.admit(); + vi.clearAllMocks(); + const input = { ...f.scope, owner: viewer.owner, userId: "alice" }; + await expect(f.service.disconnectViewer({ ...input, companyId: "foreign-company" })).rejects.toMatchObject({ code: "not_found" }); + await expect(f.service.disconnectViewer({ ...input, environmentId: "foreign-environment" })).rejects.toMatchObject({ code: "not_found" }); + await expect(f.service.disconnectViewer({ ...input, userId: "bob" })).rejects.toMatchObject({ code: "forbidden" }); + await expect(f.service.disconnectViewer({ ...input, owner: { ...viewer.owner, generation: viewer.owner.generation + 1 } })).rejects.toMatchObject({ code: "conflict" }); + await expect(f.service.disconnectViewer({ ...input, owner: { ...viewer.owner, computerId: "another-computer" } })).rejects.toMatchObject({ code: "conflict" }); + await expect(f.service.disconnectViewer({ ...input, owner: runner.owner })).rejects.toMatchObject({ code: "forbidden" }); + expect((await f.repository.get(f.scope)).ledger.owners.every(owner => owner.phase === "active")).toBe(true); + await f.service.disconnectViewer(input); + const owners = (await f.repository.get(f.scope)).ledger.owners; + expect(owners.find(owner => owner.id === viewer.owner.ownerId)?.phase).toBe("retired"); + expect(owners.find(owner => owner.id === other.owner.ownerId)?.phase).toBe("active"); + expect(owners.find(owner => owner.id === runner.owner.ownerId)?.phase).toBe("active"); + for (const method of [f.backend.inspect, f.backend.ready, f.backend.retire, f.backend.stop, f.backend.renew, f.backend.desktop]) { + expect(method).not.toHaveBeenCalled(); + } + }); it("prunes retired viewers while preserving active viewers and runner stop proof", async () => { const f = fixture(); await f.attach(); diff --git a/server/src/routes/computers.ts b/server/src/routes/computers.ts index 591db369f0e..e00115136e6 100644 --- a/server/src/routes/computers.ts +++ b/server/src/routes/computers.ts @@ -28,6 +28,13 @@ export function computerRoutes(db: Db, computers = computerService(db)) { if (!issue) return; res.setHeader("Cache-Control", "no-store"); res.setHeader("Referrer-Policy", "no-referrer"); + if (req.method === "POST" && req.path === "/disconnect") { + // A task may have moved since this viewer connected. Cleanup is scoped + // by the module to the posted environment, exact owner, and current user. + res.locals.computer = { issue }; + next(); + return; + } if (!(await settings.getExperimental()).enableBoatEnvironments && !req.path.endsWith("/disconnect")) { if (req.method === "GET") { res.json(null); return; } throw unprocessable("Boat environments are disabled."); @@ -76,13 +83,12 @@ export function computerRoutes(db: Db, computers = computerService(db)) { router.post("/issues/:issueId/computer/disconnect", async (req, res) => { const input = presenceSchema.parse(req.body); - const { environment, issue } = res.locals.computer; - if (input.environmentId !== environment.id) throw notFound("Computer not available for this task"); + const { issue } = res.locals.computer; const actor = getActorInfo(req); - await computers.disconnectViewer({ companyId: issue.companyId, environmentId: environment.id, + await computers.disconnectViewer({ companyId: issue.companyId, environmentId: input.environmentId, owner: input.owner, userId: actor.actorId }); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, - action: "computer.disconnected", entityType: "environment", entityId: environment.id, details: { issueId: issue.id } }); + action: "computer.disconnected", entityType: "environment", entityId: input.environmentId, details: { issueId: issue.id } }); res.status(204).end(); });