mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 23:20:53 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task conversations must show the saved instruction and recent replies quickly. > - Compact task reads already limit the saved message window. > - The conversation still waits for completed run transcripts even when a saved reply contains the result. > - Removing that wait alone can insert activity after the reader starts reading. > - This pull request defers that output until the reader opens history and keeps required output in the initial reveal. > - The benefit is a useful conversation sooner, with a stable first layout and a working composer. ## Linked Issues or Issue Description Refs #17593, #18092, #18312, #18358, #18422. **What happened?** Opening a task with a long saved investigation can wait for native events or legacy logs before showing its existing replies. A slow transcript request can hold the conversation behind the loading screen until the 15-second fallback. **Expected behavior** Show the bounded saved conversation without reading completed output that already has a usable saved reply. Keep that output available through the existing history controls. **Steps to reproduce** 1. Create a task with many completed runs and long saved agent replies. 2. Delay its run events and logs. 3. Open the compact task directly. Before this change, the transcript read blocks the saved replies and composer. ## What Changed - Defer completed successful run transcripts on compact task opens when a usable saved reply is present. Load that output through the existing history controls. - Keep required output on the initial path: live and failed runs, yielded runs, pending interactions, placeholder/progress-only replies, explicit anchors, chats, and full history. Retain output already selected during this visit when its run completes. - Keep saved plans visible when their owning run output is deferred or still loading. - Keep the same composer and draft through history expansion. Offer **More run output** when a single saved run has deferred output. - Position same-task comment links after their target arrives. Wait only for a missing target, with a 15-second limit. Unrelated stalled metadata cannot block an available target after the reveal deadline. - Preserve the reading anchor when a queued scroll event arrives during a properties-pane width transition. - Add a 200-reply/200-run PostgreSQL browser fixture with a saved plan, covering both task views on desktop and mobile. Correct stale browser setup for current routes and the shared mobile scroller. - Remove a random company-prefix collision in the isolated privacy test fixture that failed during CI. ## Verification - Final revision `fba3f2fcbb`: 386 focused thread/scroller unit cases and all 32 production-browser journeys passed. The browser cases cover both task views on desktop/mobile, saved plans, delayed transcripts, history, comment links, Back, drafts, retry, avatar continuity, media and pane resizing. The 25 privacy fixture cases also passed against PostgreSQL. - Workspace typecheck and full build passed with Node 24. A broader local UI run passed 10,220 cases across 849 files before the final navigation/plan review fixes; the changed components were then rechecked in the 386-case focused run. - Five alternating pairs compared master `12b0cc6304` with this revision, using 200 long saved replies and 200 completed native runs. Both production UI builds used the same loopback API/database, Chromium 151.0.7922.34, 1440 × 900, fresh browser contexts, service workers blocked, no CPU throttle, and a controlled 3-second events/log delay. Builds and other suites were stopped during measurement. No page errors occurred. | Useful conversation + editable composer | Baseline median (range) | Fixed median (range) | | --- | --- | --- | | Cold direct open | 3.570s (3.538–3.616s) | 1.056s (1.022–1.141s) | | Task-list navigation | 3.373s (3.342–3.394s) | 0.899s (0.885–0.915s) | | Back | 22ms (22–25ms) | 25ms (25–28ms) | - Median paired savings: 2.519s cold, 2.470s from the list. Completed-output requests: two per baseline sample, zero per fixed sample. Recent-comments API control medians were 13.6ms for both, with ranges 12.1–18.7ms and 11.6–50.8ms. No Back improvement is claimed; an earlier revision's run also included one 1-second Back outlier, retained in the local evidence. - The full local default test command did **not** pass: it stopped on the existing chat test line-selection error. After restoring embedded PostgreSQL library symlinks omitted by an install with scripts disabled, Node 24 passed 672 cases in the first two chat shards, then failed to select the third. This is separate from the passing focused suites and remote CI. - Earlier CI failures were addressed: the privacy prefix collision is fixed; the delegated task-completion reply case passed locally after merging master's reply-recovery fix (#18465). Browser fixture corrections retain the original behavior assertions. Red tests reproduced the loading, navigation, resize and saved-plan regressions before their fixes. - Map, module-boundary, token-gate and added-line token checks passed. The full-tree token scan still finds pre-existing active-username fixtures; this PR adds zero forbidden token lines. - Greptile: **5/5**, final revision, all four review threads resolved. The final-revision PR CI workflow passed, including build, typecheck, all server/chat/workspace test shards, all eight E2E shards, and every Runner lane. GitHub reports 55 successful checks and three skipped checks. The separate **Paperclip Review** check remains queued with **Waiting for the assigned agent**; it has not produced an assessment and has not been bypassed. The branch has no merge conflicts. ## Risks - The default compact task omits completed transcript details when the saved result is sufficient. The reader must open history to inspect them. - Required structural data remains inside the initial reveal boundary. Slow server responses for that data remain visible latency. - Link positioning falls back after 15 seconds when its target never arrives. The conversation remains readable. - The measurements use a controlled local delay. They do not establish a deployed staging speedup. - No API, schema, authorization, or migration changes. ## Model Used OpenAI Codex, GPT-6, with tool use and code execution. ## 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 - [ ] I have run tests locally and they pass (focused suites pass; the full default wrapper has the existing selection error documented above) - [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 - [ ] All Paperclip CI gates are green (code CI passed; the separate assigned-agent Paperclip Review remains queued) - [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>
489 lines
39 KiB
TypeScript
489 lines
39 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import { Readable } from "node:stream";
|
|
import { once } from "node:events";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import WebSocket from "ws";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { workspaceOperations, agents, approvals, assets, chatEndpoints, chatExternalPrincipals, chatConversations, chatIdentityLinks, chatDeliveries, toolApplications, toolConnections, executionWorkspaces, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issueAccessGrants, issues, projectAccessMembers, projects, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
|
|
import { authorizationService, canPublishIssueToChatAudience, canActorReadIssuePrivacy, issueReadSqlCondition, canActorReadProjectPrivacy, projectReadSqlCondition, canActorReadApproval, approvalReadSqlCondition, canActorReadExecutionWorkspace, type AuthorizationActor } from "../services/authorization.js";
|
|
|
|
import { canActorReadWorkspaceOperation } from "../services/heartbeat-run-privacy.js";
|
|
|
|
// Production regressions and the approved downward-only sharing contract.
|
|
describe("private task production review", () => {
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
let fixtureCompanyNumber = 0;
|
|
beforeAll(async () => {
|
|
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-private-review-");
|
|
db = createDb(tempDb.connectionString);
|
|
// Load the current route dependency graph during setup, outside assertion timeouts.
|
|
await Promise.all([import("../routes/issues.js"), import("../routes/projects.js")]);
|
|
}, 120_000);
|
|
afterAll(async () => { await tempDb?.cleanup(); });
|
|
|
|
async function fixture() {
|
|
const [company] = await db.insert(companies).values({ name: randomUUID(), issuePrefix: `T${++fixtureCompanyNumber}` }).returning();
|
|
const owner = randomUUID(), outsider = randomUUID();
|
|
await db.insert(authUsers).values([owner, outsider].map(id => ({ id, name: id, email: `${id}@example.test`, createdAt: new Date(), updatedAt: new Date() })));
|
|
await db.insert(companyMemberships).values([owner, outsider].map(principalId => ({ companyId: company.id, principalType: "user", principalId, status: "active", membershipRole: "operator" })));
|
|
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Shared agent", role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, permissions: {} }).returning();
|
|
const actor = (userId: string): AuthorizationActor => ({ type: "board", userId, source: "session", companyIds: [company.id] });
|
|
return { company, owner, outsider, agent, actor };
|
|
}
|
|
|
|
it("lets only the project privacy owner or an admin change its audience", async () => {
|
|
const f = await fixture();
|
|
const { projectRoutes } = await import("../routes/projects.js");
|
|
const { errorHandler } = await import("../middleware/index.js");
|
|
let actor = f.actor(f.owner);
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = actor; next(); });
|
|
app.use("/api", projectRoutes(db));
|
|
app.use(errorHandler);
|
|
const created = await request(app).post(`/api/companies/${f.company.id}/projects`).send({ name: "Private project", visibility: "private", privacyOwnerUserId: f.outsider });
|
|
expect(created.status).toBe(201);
|
|
expect(created.body.privacyOwnerUserId).toBe(f.owner);
|
|
const projectId = created.body.id;
|
|
const member = await request(app).post(`/api/projects/${projectId}/access-members`).send({ subjectType: "user", subjectId: f.outsider });
|
|
expect(member.status).toBe(201);
|
|
actor = f.actor(f.outsider);
|
|
expect((await request(app).get(`/api/projects/${projectId}`)).status).toBe(200);
|
|
expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "open" })).status).toBe(403);
|
|
expect((await request(app).post(`/api/projects/${projectId}/access-members`).send({ subjectType: "agent", subjectId: f.agent.id })).status).toBe(403);
|
|
expect((await request(app).delete(`/api/projects/${projectId}/access-members/${member.body.id}`)).status).toBe(403);
|
|
actor = f.actor(f.owner);
|
|
expect((await request(app).delete(`/api/projects/${projectId}/access-members/${member.body.id}`)).status).toBe(200);
|
|
expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "open" })).status).toBe(200);
|
|
await db.update(companyMemberships).set({ membershipRole: "admin" }).where(and(eq(companyMemberships.companyId, f.company.id), eq(companyMemberships.principalId, f.outsider)));
|
|
actor = f.actor(f.outsider);
|
|
expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "private" })).status).toBe(200);
|
|
});
|
|
|
|
it("publishes private chat content only to a currently authorized linked DM recipient", async () => {
|
|
const f = await fixture();
|
|
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private chat", visibility: "private", responsibleUserId: f.owner }).returning();
|
|
const [application] = await db.insert(toolApplications).values({ companyId: f.company.id, name: "Test chat", type: "native" }).returning();
|
|
const [connection] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: application.id, name: "Test chat", uid: "test-chat", connectionPurpose: "channel", transport: "chat_sdk" }).returning();
|
|
const [endpoint] = await db.insert(chatEndpoints).values({ companyId: f.company.id, connectionId: connection.id, provider: "slack", publicId: randomUUID(), assignedAgentId: f.agent.id }).returning();
|
|
const [principal] = await db.insert(chatExternalPrincipals).values({ companyId: f.company.id, provider: "slack", providerAccountId: "test", externalId: "person", kind: "user" }).returning();
|
|
const [conversation] = await db.insert(chatConversations).values({ companyId: f.company.id, endpointId: endpoint.id, issueId: task.id, externalConversationId: "dm", externalLabel: "Test DM", isDirectMessage: true }).returning();
|
|
const publication = { companyId: f.company.id, issueId: task.id, endpointId: endpoint.id, conversationId: conversation.id };
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
|
await db.insert(chatDeliveries).values({ companyId: f.company.id, endpointId: endpoint.id, conversationId: conversation.id, principalId: principal.id, providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "message", normalizedEvent: {} as any });
|
|
const [link] = await db.insert(chatIdentityLinks).values({ companyId: f.company.id, endpointId: endpoint.id, principalId: principal.id, paperclipUserId: f.outsider, status: "linked" }).returning();
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
|
const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(true);
|
|
await db.update(chatConversations).set({ isDirectMessage: false }).where(eq(chatConversations.id, conversation.id));
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
|
await db.update(chatConversations).set({ isDirectMessage: true }).where(eq(chatConversations.id, conversation.id));
|
|
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
|
await db.update(chatIdentityLinks).set({ paperclipUserId: f.owner }).where(eq(chatIdentityLinks.id, link.id));
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(true);
|
|
await db.update(chatIdentityLinks).set({ revokedAt: new Date() }).where(eq(chatIdentityLinks.id, link.id));
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
|
});
|
|
|
|
it("a shared agent cannot read an owner's private task on behalf of an unauthorized coworker", async () => {
|
|
const f = await fixture();
|
|
const id = randomUUID();
|
|
const [issue] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Private email", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, assigneeAgentId: f.agent.id }).returning();
|
|
const authz = authorizationService(db);
|
|
const resource = { type: "issue" as const, companyId: f.company.id, issueId: id };
|
|
expect((await authz.decide({ actor: f.actor(f.outsider), action: "issue:read", resource })).allowed).toBe(false);
|
|
expect((await authz.decide({ actor: { type: "agent", agentId: f.agent.id, companyId: f.company.id, source: "agent_jwt", onBehalfOfUserId: f.outsider }, action: "issue:read", resource })).allowed).toBe(false);
|
|
});
|
|
|
|
it("list SQL and direct checks agree after a project becomes open", async () => {
|
|
const f = await fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Previously private", visibility: "open" }).returning();
|
|
await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "user", subjectId: f.outsider });
|
|
const id = randomUUID();
|
|
const [issue] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Still individually private", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, projectId: project.id }).returning();
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), issue)).toBe(false);
|
|
const found = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, id), await issueReadSqlCondition(db, f.actor(f.outsider))));
|
|
expect(found).toEqual([]);
|
|
});
|
|
|
|
it("making a parent private also protects existing descendants", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const parent = await svc.create(f.company.id, { title: "Parent", createdByUserId: f.owner });
|
|
const child = await svc.create(f.company.id, { title: "Existing child", parentId: parent.id, createdByUserId: f.owner });
|
|
await svc.update(parent.id, { visibility: "private" });
|
|
const updated = await svc.getById(child.id);
|
|
expect(updated?.visibility).toBe("private");
|
|
});
|
|
|
|
it("restricting a project protects descendants outside it and preserves privacy when reopened", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const { projectService } = await import("../services/projects.js");
|
|
const svc = issueService(db);
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Project", visibility: "open" }).returning();
|
|
const root = await svc.create(f.company.id, { title: "Root", projectId: project.id, createdByUserId: f.owner });
|
|
const child = await svc.create(f.company.id, { title: "Child", parentId: root.id, createdByUserId: f.owner });
|
|
const grandchild = await svc.create(f.company.id, { title: "Grandchild", parentId: child.id, createdByUserId: f.owner });
|
|
await projectService(db).update(project.id, { visibility: "private" });
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false);
|
|
await projectService(db).update(project.id, { visibility: "open" });
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false);
|
|
expect((await svc.getById(child.id))?.visibility).toBe("private");
|
|
});
|
|
|
|
it("publishing a personal task leaves its private project while keeping its children private", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const root = await svc.create(f.company.id, { title: "Personal", visibility: "private", createdByUserId: f.owner });
|
|
const child = await svc.create(f.company.id, { title: "Child", parentId: root.id, createdByUserId: f.owner });
|
|
const published = await svc.update(root.id, { visibility: "open" });
|
|
expect(published?.projectId).toBeNull();
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), published!)).toBe(true);
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), child)).toBe(false);
|
|
});
|
|
|
|
it("reparenting an open issue under a private task inherits privacy", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const parent = await svc.create(f.company.id, { title: "Parent", createdByUserId: f.owner, visibility: "private" });
|
|
const child = await svc.create(f.company.id, { title: "Moved child", createdByUserId: f.owner });
|
|
const updated = await svc.update(child.id, { parentId: parent.id });
|
|
expect(updated?.visibility).toBe("private");
|
|
});
|
|
|
|
it("allows task management hints in an unreadable personal project without disclosing its identity", async () => {
|
|
const f = await fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Protected personal context", visibility: "private", personalOwnerUserId: f.outsider, privacyOwnerUserId: f.outsider }).returning();
|
|
const id = randomUUID();
|
|
const [task] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Owned task", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, projectId: project.id }).returning();
|
|
const { issueRoutes } = await import("../routes/issues.js");
|
|
const { projectRoutes } = await import("../routes/projects.js");
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = f.actor(f.owner) as Express.Request["actor"]; next(); });
|
|
app.use("/api", issueRoutes(db, {} as any)); app.use("/api", projectRoutes(db));
|
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); });
|
|
await request(app).get(`/api/projects/${project.id}`).expect(404);
|
|
const response = await request(app).get(`/api/issues/${task.id}/privacy-constraints`).expect(200);
|
|
expect(response.body).toEqual({ publicBlockedBy: null, leavesPersonalProject: true });
|
|
const { issueService } = await import("../services/issues.js");
|
|
expect(await issueService(db).update(task.id, { visibility: "open" })).toMatchObject({ visibility: "open", projectId: null });
|
|
});
|
|
|
|
it("reports an inaccessible private parent as a move-first constraint without leaking it", async () => {
|
|
const f = await fixture();
|
|
const parentId = randomUUID(), childId = randomUUID();
|
|
await db.insert(issues).values({ id: parentId, companyId: f.company.id, title: "Hidden parent content", visibility: "private", privacyRootIssueId: parentId, responsibleUserId: f.outsider });
|
|
await db.insert(issues).values({ id: childId, companyId: f.company.id, title: "Owned child", visibility: "private", privacyRootIssueId: parentId, privacyParentIssueId: parentId, responsibleUserId: f.owner });
|
|
const { issueRoutes } = await import("../routes/issues.js");
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = f.actor(f.owner) as Express.Request["actor"]; next(); });
|
|
app.use("/api", issueRoutes(db, {} as any));
|
|
await request(app).get(`/api/issues/${parentId}`).expect(404);
|
|
const response = await request(app).get(`/api/issues/${childId}/privacy-constraints`).expect(200);
|
|
expect(response.body).toEqual({ publicBlockedBy: "parent", leavesPersonalProject: false });
|
|
});
|
|
|
|
it("does not expose task management hints to a shared reader or unrelated company", async () => {
|
|
const f = await fixture(); const id = randomUUID();
|
|
await db.insert(issues).values({ id, companyId: f.company.id, title: "Private", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner });
|
|
const { issueRoutes } = await import("../routes/issues.js");
|
|
let actor = f.actor(f.outsider);
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = actor as Express.Request["actor"]; next(); });
|
|
app.use("/api", issueRoutes(db, {} as any));
|
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); });
|
|
await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(404);
|
|
await db.insert(issueAccessGrants).values({ issueId: id, subjectType: "user", subjectId: f.outsider, source: "explicit" });
|
|
await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(403);
|
|
actor = { ...f.actor(f.owner), companyIds: [] };
|
|
await db.update(companyMemberships).set({ status: "archived" }).where(and(eq(companyMemberships.companyId, f.company.id), eq(companyMemberships.principalId, f.owner)));
|
|
const denied = await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(404);
|
|
expect(denied.body.publicBlockedBy).toBeUndefined();
|
|
});
|
|
|
|
it("an unauthorized board user cannot fetch a private issue through PATCH", async () => {
|
|
const f = await fixture();
|
|
const id = randomUUID();
|
|
await db.insert(issues).values({ id, companyId: f.company.id, title: "Confidential acquisition", description: "Private email content", status: "backlog", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, createdByUserId: f.owner });
|
|
const { issueRoutes } = await import("../routes/issues.js");
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = f.actor(f.outsider) as Express.Request["actor"]; next(); });
|
|
app.use("/api", issueRoutes(db, {} as any));
|
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); });
|
|
await request(app).get(`/api/issues/${id}`).expect(404);
|
|
const response = await request(app).patch(`/api/issues/${id}`).send({ priority: "low" });
|
|
expect({ status: response.status, title: response.body.title }).toEqual({ status: 404, title: undefined });
|
|
});
|
|
|
|
it("sharing a child grants its descendants, never its parent or sibling, and revokes immediately", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const root = await svc.create(f.company.id, { title: "Root", visibility: "private", createdByUserId: f.owner });
|
|
const child = await svc.create(f.company.id, { title: "Shared child", parentId: root.id, createdByUserId: f.owner });
|
|
const grandchild = await svc.create(f.company.id, { title: "Grandchild", parentId: child.id, createdByUserId: f.owner });
|
|
const sibling = await svc.create(f.company.id, { title: "Sibling", parentId: root.id, createdByUserId: f.owner });
|
|
const [grant] = await db.insert(issueAccessGrants).values({ issueId: child.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
|
for (const [task, expected] of [[root, false], [child, true], [grandchild, true], [sibling, false]] as const) {
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), task)).toBe(expected);
|
|
const rows = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, task.id), await issueReadSqlCondition(db, f.actor(f.outsider))));
|
|
expect(rows.length > 0).toBe(expected);
|
|
}
|
|
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), child)).toBe(false);
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false);
|
|
});
|
|
|
|
it("binds child-manager grant writes to the child and rejects ancestor or sibling grants", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const { issueRoutes } = await import("../routes/issues.js");
|
|
const { errorHandler } = await import("../middleware/index.js");
|
|
const svc = issueService(db);
|
|
const root = await svc.create(f.company.id, { title: "Ancestor", visibility: "private", createdByUserId: f.outsider });
|
|
const child = await svc.create(f.company.id, { title: "Managed child", parentId: root.id, createdByUserId: f.owner });
|
|
const grandchild = await svc.create(f.company.id, { title: "Descendant", parentId: child.id, createdByUserId: f.outsider });
|
|
const sibling = await svc.create(f.company.id, { title: "Sibling", parentId: root.id, createdByUserId: f.outsider });
|
|
const [rootGrant] = await db.insert(issueAccessGrants).values({ issueId: root.id, subjectType: "user", subjectId: f.owner, source: "explicit" }).returning();
|
|
const [siblingGrant] = await db.insert(issueAccessGrants).values({ issueId: sibling.id, subjectType: "user", subjectId: f.owner, source: "explicit" }).returning();
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = f.actor(f.owner); next(); });
|
|
app.use("/api", issueRoutes(db, {} as any)); app.use(errorHandler);
|
|
const audience = await request(app).get(`/api/issues/${child.id}/access-grants`).expect(200);
|
|
expect(audience.body).toContainEqual(expect.objectContaining({ id: rootGrant.id, inherited: true }));
|
|
for (const grant of [rootGrant, siblingGrant]) {
|
|
await request(app).post(`/api/issues/${child.id}/access-grants/${grant.id}/revoke`).send({}).expect(404);
|
|
const [unchanged] = await db.select().from(issueAccessGrants).where(eq(issueAccessGrants.id, grant.id));
|
|
expect(unchanged.revokedAt).toBeNull();
|
|
}
|
|
for (const task of [root, sibling]) {
|
|
await request(app).post(`/api/issues/${task.id}/access-grants`).send({ subjectType: "agent", subjectId: f.agent.id }).expect(403);
|
|
}
|
|
const added = await request(app).post(`/api/issues/${child.id}/access-grants`).send({ subjectType: "agent", subjectId: f.agent.id }).expect(201);
|
|
expect(added.body.issueId).toBe(child.id);
|
|
const agent: AuthorizationActor = { type: "agent", companyId: f.company.id, agentId: f.agent.id };
|
|
for (const [task, expected] of [[root, false], [child, true], [grandchild, true], [sibling, false]] as const) {
|
|
expect(await canActorReadIssuePrivacy(db, agent, task)).toBe(expected);
|
|
}
|
|
await request(app).post(`/api/issues/${child.id}/access-grants/${added.body.id}/revoke`).send({}).expect(200);
|
|
expect(await canActorReadIssuePrivacy(db, agent, child)).toBe(false);
|
|
expect(await canActorReadIssuePrivacy(db, agent, grandchild)).toBe(false);
|
|
});
|
|
|
|
it("retains a child assignment grant after unassignment without granting the root", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const root = await svc.create(f.company.id, { title: "Root", visibility: "private", createdByUserId: f.owner });
|
|
const child = await svc.create(f.company.id, { title: "Assigned child", parentId: root.id, createdByUserId: f.owner, assigneeAgentId: f.agent.id });
|
|
await svc.update(child.id, { assigneeAgentId: null });
|
|
const actor: AuthorizationActor = { type: "agent", agentId: f.agent.id, companyId: f.company.id };
|
|
expect(await canActorReadIssuePrivacy(db, actor, child)).toBe(true);
|
|
expect(await canActorReadIssuePrivacy(db, actor, root)).toBe(false);
|
|
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.issueId, child.id));
|
|
expect(await canActorReadIssuePrivacy(db, actor, child)).toBe(false);
|
|
});
|
|
|
|
it("protects standalone tasks created from a private run", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const source = await svc.create(f.company.id, { title: "Private conversation", visibility: "private", createdByUserId: f.owner });
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", nativeIssueId: source.id }).returning();
|
|
const handoff = await svc.create(f.company.id, { title: "Standalone handoff", createdByUserId: f.owner, originRunId: run.id });
|
|
expect(handoff).toMatchObject({ parentId: null, privacyParentIssueId: source.id, visibility: "private" });
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), handoff)).toBe(false);
|
|
});
|
|
|
|
it("intersects agent and responsible-user access to private projects in direct and list reads", async () => {
|
|
const f = await fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Private project", visibility: "private" }).returning();
|
|
await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "agent", subjectId: f.agent.id });
|
|
const actor: AuthorizationActor = { type: "agent", agentId: f.agent.id, companyId: f.company.id, onBehalfOfUserId: f.outsider };
|
|
expect(await canActorReadProjectPrivacy(db, actor, project)).toBe(false);
|
|
expect(await db.select().from(projects).where(and(eq(projects.id, project.id), await projectReadSqlCondition(db, actor)))).toEqual([]);
|
|
await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "user", subjectId: f.outsider });
|
|
expect(await canActorReadProjectPrivacy(db, actor, project)).toBe(true);
|
|
});
|
|
|
|
it("binds native runs and keeps missing-task history fail closed", async () => {
|
|
const f = await fixture();
|
|
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", nativeIssueId: task.id }).returning();
|
|
expect(run).toMatchObject({ scopeKind: "issue", issueId: task.id });
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: {}, scopeKind: "company" }).where(eq(heartbeatRuns.id, run.id));
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id))).toMatchObject([{ scopeKind: "issue", issueId: task.id }]);
|
|
const [orphan] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", contextSnapshot: { issueId: randomUUID() } }).returning();
|
|
expect(orphan).toMatchObject({ scopeKind: "issue", issueId: null });
|
|
});
|
|
|
|
it("filters private and orphaned operation excerpts from a shared workspace listing", async () => {
|
|
const f = await fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Shared" }).returning();
|
|
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.company.id, projectId: project.id, mode: "isolated_workspace", strategyType: "git_worktree", name: "Shared workspace" }).returning();
|
|
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
|
await db.insert(workspaceOperations).values({ companyId: f.company.id, executionWorkspaceId: workspace.id, issueId: task.id, phase: "prepare", stdoutExcerpt: "PRIVATE_OPERATION" });
|
|
const [publicOperation] = await db.insert(workspaceOperations).values({ companyId: f.company.id, executionWorkspaceId: workspace.id, phase: "prepare", stdoutExcerpt: "PUBLIC_OPERATION" }).returning();
|
|
const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js");
|
|
const { errorHandler } = await import("../middleware/index.js");
|
|
let actor = f.actor(f.outsider);
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = actor; next(); });
|
|
app.use("/api", executionWorkspaceRoutes(db)); app.use(errorHandler);
|
|
const path = `/api/execution-workspaces/${workspace.id}/workspace-operations`;
|
|
const outside = await request(app).get(path);
|
|
expect(outside.status).toBe(200);
|
|
expect(outside.body.map((row: { id: string }) => row.id)).toEqual([publicOperation.id]);
|
|
actor = f.actor(f.owner);
|
|
expect((await request(app).get(path)).body).toHaveLength(2);
|
|
await db.delete(issues).where(eq(issues.id, task.id));
|
|
expect((await request(app).get(path)).body.map((row: { id: string }) => row.id)).toEqual([publicOperation.id]);
|
|
});
|
|
|
|
it("keeps operation history private even with a company run and after task or run deletion", async () => {
|
|
const f = await fixture();
|
|
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded" }).returning();
|
|
expect(run.scopeKind).toBe("company");
|
|
const [operation] = await db.insert(workspaceOperations).values({ companyId: f.company.id, issueId: task.id, heartbeatRunId: run.id, phase: "prepare", stdoutExcerpt: "PRIVATE_LOG" }).returning();
|
|
const access = authorizationService(db);
|
|
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), operation)).toBe(true);
|
|
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.outsider), operation)).toBe(false);
|
|
await db.delete(issues).where(eq(issues.id, task.id));
|
|
const [orphan] = await db.select().from(workspaceOperations).where(eq(workspaceOperations.id, operation.id));
|
|
expect(orphan).toMatchObject({ issueId: null, stdoutExcerpt: "PRIVATE_LOG", metadata: { _issuePrivacySources: { [task.id]: true } } });
|
|
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), orphan)).toBe(false);
|
|
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
|
|
const [history] = await db.update(workspaceOperations).set({ metadata: {} }).where(eq(workspaceOperations.id, operation.id)).returning();
|
|
expect(history).toMatchObject({ heartbeatRunId: null, metadata: { _runPrivacySources: { [run.id]: true }, _issuePrivacySources: { [task.id]: true } } });
|
|
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), history)).toBe(false);
|
|
});
|
|
|
|
it("delivers authorized live output, then stops delivery after revocation on the same socket", async () => {
|
|
const f = await fixture();
|
|
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", nativeIssueId: task.id }).returning();
|
|
const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
|
const { setupLiveEventsWebSocketServer } = await import("../realtime/live-events-ws.js");
|
|
const { publishLiveEvent } = await import("../services/live-events.js");
|
|
const server = createServer();
|
|
const wss = setupLiveEventsWebSocketServer(server, db, { deploymentMode: "authenticated", resolveCloudActor: async () => ({ userId: f.outsider, companyIds: [f.company.id] }) });
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/${f.company.id}/events/ws`);
|
|
const messages: string[] = [];
|
|
socket.on("message", data => messages.push(data.toString()));
|
|
try {
|
|
await once(socket, "open");
|
|
const publish = (chunk: string) => publishLiveEvent({ companyId: f.company.id, type: "heartbeat.run.log", payload: { issueId: task.id, runId: run.id, agentId: f.agent.id, chunk } });
|
|
publish("AUTHORIZED_CANARY");
|
|
await expect.poll(() => messages.some(message => message.includes("AUTHORIZED_CANARY"))).toBe(true);
|
|
publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "authorized_activity", entityType: "issue", entityId: task.id } });
|
|
await expect.poll(() => messages.some(message => message.includes("authorized_activity"))).toBe(true);
|
|
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
|
publish("REVOKED_CANARY");
|
|
publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "revoked_activity", entityType: "issue", entityId: task.id } });
|
|
publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "queue_drained", entityType: "company", entityId: f.company.id } });
|
|
await expect.poll(() => messages.some(message => message.includes("queue_drained"))).toBe(true);
|
|
expect(messages.join(" ")).not.toContain("REVOKED_CANARY");
|
|
expect(messages.join(" ")).not.toContain("revoked_activity");
|
|
} finally {
|
|
socket.terminate();
|
|
await new Promise<void>(resolve => (wss as any).close(resolve));
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("protects linked approval payloads and execution workspace details", async () => {
|
|
const f = await fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Public project" }).returning();
|
|
const [task] = await db.insert(issues).values({ companyId: f.company.id, projectId: project.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
|
const [approval] = await db.insert(approvals).values({ companyId: f.company.id, type: "hire_agent", payload: { sourceIssueId: task.id, instructions: "PRIVATE_PAYLOAD" } }).returning();
|
|
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.company.id, projectId: project.id, sourceIssueId: task.id, mode: "isolated_workspace", strategyType: "git_worktree", name: "Private workspace" }).returning();
|
|
expect(await canActorReadApproval(db, f.actor(f.owner), approval.id)).toBe(true);
|
|
expect(await canActorReadApproval(db, f.actor(f.outsider), approval.id)).toBe(false);
|
|
expect(await db.select().from(approvals).where(and(eq(approvals.id, approval.id), await approvalReadSqlCondition(db, f.actor(f.outsider))))).toEqual([]);
|
|
expect(await canActorReadExecutionWorkspace(db, f.actor(f.owner), workspace.id)).toBe(true);
|
|
expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false);
|
|
await db.update(executionWorkspaces).set({ sourceIssueId: null, metadata: {} }).where(eq(executionWorkspaces.id, workspace.id));
|
|
expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false);
|
|
await db.delete(issues).where(eq(issues.id, task.id));
|
|
expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false);
|
|
});
|
|
|
|
it("keeps inline draft images owner-only until publication then follows the task grant", async () => {
|
|
const f = await fixture();
|
|
const [asset] = await db.insert(assets).values({ companyId: f.company.id, provider: "local_disk", objectKey: `${f.company.id}/assets/issues/drafts/image.png`, contentType: "image/png", byteSize: 6, sha256: "test", createdByUserId: f.owner }).returning();
|
|
const { assetRoutes } = await import("../routes/assets.js");
|
|
const { issueService } = await import("../services/issues.js");
|
|
function appFor(userId: string) {
|
|
const app = express();
|
|
app.use((req, _res, next) => { req.actor = f.actor(userId) as any; next(); });
|
|
app.use("/api", assetRoutes(db, { getObject: async () => ({ stream: Readable.from("SECRET"), contentLength: 6 }) } as any));
|
|
return app;
|
|
}
|
|
const url = `/api/assets/${asset.id}/content`;
|
|
await request(appFor(f.owner)).get(url).expect(200);
|
|
await request(appFor(f.outsider)).get(url).expect(404);
|
|
const task = await issueService(db).create(f.company.id, { title: "Private draft", visibility: "private", createdByUserId: f.owner, description: `` });
|
|
const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
|
await request(appFor(f.outsider)).get(url).expect(200);
|
|
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
|
await request(appFor(f.outsider)).get(url).expect(404);
|
|
await db.delete(issues).where(eq(issues.id, task.id));
|
|
await request(appFor(f.outsider)).get(url).expect(404);
|
|
});
|
|
|
|
it("protects attachment assets even after their attachment or task is deleted", async () => {
|
|
const f = await fixture();
|
|
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
|
const [asset] = await db.insert(assets).values({ companyId: f.company.id, provider: "local_disk", objectKey: `${f.company.id}/issues/${task.id}/file.txt`, contentType: "text/plain", byteSize: 6, sha256: "test" }).returning();
|
|
const { assetRoutes } = await import("../routes/assets.js");
|
|
const appFor = (userId: string) => {
|
|
const app = express();
|
|
app.use((req, _res, next) => { req.actor = f.actor(userId) as Express.Request["actor"]; next(); });
|
|
app.use("/api", assetRoutes(db, { getObject: async () => ({ stream: Readable.from("SECRET"), contentLength: 6 }) } as any));
|
|
return app;
|
|
};
|
|
await request(appFor(f.owner)).get(`/api/assets/${asset.id}/content`).expect(200, "SECRET");
|
|
await request(appFor(f.outsider)).get(`/api/assets/${asset.id}/content`).expect(404);
|
|
await db.delete(issues).where(eq(issues.id, task.id));
|
|
await request(appFor(f.owner)).get(`/api/assets/${asset.id}/content`).expect(404);
|
|
});
|
|
|
|
it("live WebSocket delivery does not disclose a private run to an unauthorized member", async () => {
|
|
const f = await fixture();
|
|
const id = randomUUID();
|
|
await db.insert(issues).values({ id, companyId: f.company.id, title: "Private email", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner });
|
|
expect((await authorizationService(db).decide({ actor: f.actor(f.outsider), action: "issue:read", resource: { type: "issue", companyId: f.company.id, issueId: id } })).allowed).toBe(false);
|
|
const { setupLiveEventsWebSocketServer } = await import("../realtime/live-events-ws.js");
|
|
const { publishLiveEvent } = await import("../services/live-events.js");
|
|
const server = createServer();
|
|
const wss = setupLiveEventsWebSocketServer(server, db, { deploymentMode: "authenticated", resolveCloudActor: async () => ({ userId: f.outsider, companyIds: [f.company.id] }) });
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address() as { port: number };
|
|
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/companies/${f.company.id}/events/ws`);
|
|
try {
|
|
await once(socket, "open");
|
|
const message = once(socket, "message");
|
|
publishLiveEvent({ companyId: f.company.id, type: "heartbeat.run.log", payload: { issueId: id, runId: randomUUID(), agentId: f.agent.id, chunk: "PRIVATE_EMAIL_CANARY" } });
|
|
const result = await Promise.race([message.then(([data]) => data.toString()), new Promise<string>(resolve => setTimeout(() => resolve(""), 150))]);
|
|
expect(result).not.toContain("PRIVATE_EMAIL_CANARY");
|
|
} finally {
|
|
socket.terminate();
|
|
await new Promise<void>(resolve => (wss as any).close(resolve));
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
}
|
|
});
|
|
});
|