Files
PaperClipAI/server/src/__tests__/privacy-production-review.test.ts
T
DottaandPaperclip 91fdf02e95 perf(tasks): defer completed transcripts on compact opens (#18461)
## 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>
2026-10-31 12:56:06 -05:00

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: `![image](${url})` });
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()));
}
});
});