mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
5c3cd777df
commit
754a4e792e
2 files changed
+34
-4
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user