Files
PaperClipAI/server/src/__tests__/task-workspace-api.test.ts
T
DottaandPaperclip 55389554c2 fix(runner): paginate workspace discovery before journal persistence (#19311)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native agents discover reusable workspaces through
`list_workspaces`.
> - The tool returned every authorized workspace in one result.
> - A large inventory exceeded the durable command journal limit. The
result stayed pending, and session cleanup blocked the next turn.
> - This pull request limits discovery in SQL and returns explicit
continuation metadata.
> - Agents can inspect every authorized workspace without sending the
full inventory in one command.

## Linked Issues or Issue Description

Related: #15749 introduced the task workspace tools. No duplicate
pagination fix was found.

**What happened?**

A native workspace discovery call returned a multi-megabyte inventory.
Persisting its result failed with `command_payload_too_large`. The
preceding answer could still be saved, but cleanup retained the
unsettled tool call. A later turn failed before provider work with
`native_session_cleanup_quarantined`.

**Expected behavior**

Workspace discovery must return a manageable page. Agents must be able
to request the remaining authorized rows. Existing visibility checks and
journal integrity checks must stay effective.

**Steps to reproduce**

1. Create 2,000 task-selectable workspaces with ordinary repository
paths.
2. Call `list_workspaces` from an admitted native run.
3. On the old code, the real API regression returns 869,781 bytes. The
command journal allows about 510 KiB.
4. On the repaired code, follow `nextCursor` to inspect all rows. Pages
contain at most 100 rows and fit within 128 KiB.

**Paperclip version or commit**

Reproduced on source commit `7351a9d74f` before the repair.

**Deployment mode**

Isolated source checkout with real PostgreSQL and authenticated native
agent APIs. Hosted deployment and incident recovery remain separate.

## What Changed

- Validate workspace list limits and UUID cursors with a JSON Schema
pattern supported by strict validators. Return `workspaces`, `limit`,
`returnedCount`, `hasMore`, and `nextCursor`.
- Apply company, visibility, lifecycle, and cursor conditions before the
SQL limit. Order pages by workspace ID. Fetch one extra row to detect
continuation without a count query.
- Fit encoded pages within 128 KiB, including JSON escaping and Unicode.
If one summary cannot fit, return explicit string previews with
`truncatedFields`. The authorized detail API retains its full record.
- Keep existing unpaged summary callers on their array response and
recent-use order.
- Update the native tool descriptor, generated contracts, catalog
fixture, and execution documentation.
- Add regression coverage for large inventories, complete pagination,
stable pages, private and foreign rows, invalid inputs, and legacy
summary behavior. Add an opt-in two-turn live Codex acceptance check.

## Verification

- Red before repair: `task-workspace-api.test.ts -t "pages a large
authorized workspace inventory"` failed because the result was 869,781
bytes instead of below 128 KiB. The transport regression also failed at
2,284,671 bytes.
- Green after repair and review changes: 120 tests passed across the
workspace API, transport, routes, native authority, coordinator,
historical-question, and Codex vertical-slice suites. They cover
complete discovery, byte-sized pages, oversized individual summaries,
full detail retrieval, private/foreign/lifecycle exclusions, and strict
schema compatibility.
- Journal safety: two focused durable control-plane tests passed.
Oversized result rejection and lossless valid result delivery remain
effective.
- Tool catalog and reconciliation: 30 tests passed.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed again on final commit `bc10880da6`. Server
typecheck also passed after the review changes. The original affected
suites passed on the rebased first commit (31 tests), and the expanded
review regression set passed afterward (120 tests).
- Real provider acceptance passed again on final commit `bc10880da6`:
local macOS arm64, rebuilt native Runner, `gpt-6.1-sol`, high effort,
per-turn lifecycle, and 2,000 persisted workspaces. Both turns
succeeded. Persisted call inputs and results prove that the second page
uses the first result’s cursor, both calls succeed, their IDs do not
overlap, and the encoded results stay within 128 KiB. The second turn
kept the provider conversation and file. Both task locks were clear
after completion, and no recovered execution failure was recorded.
Duration: 166.92 seconds.
- Full local `pnpm test:run` stopped in the chat shard: 330 tests passed
and one Slack registration test failed with `socket hang up`. That exact
test passed in isolation without changes, and the final-head CI chat
shards passed. A broader local managed-Slack follow-up produced no
result after about eight minutes and was interrupted; it is not counted
as a pass. The original full-suite failure remains recorded.
- The first CI head failed native tool-schema compilation because strict
validators did not recognize `format: uuid`. The revised descriptor uses
a UUID pattern. The previously failing coordinator, native
vertical-slice, authority, and history suites now pass locally. The
stronger live cursor-receipt acceptance check passed. Final-head CI
completed on `bc10880da6`: 57 checks passed and five optional checks
were skipped, with no failures or pending checks. Greptile reviewed that
commit at 5/5; both prior findings are resolved.
- Dependency cost: paged discovery runs the existing visibility-scoped
summary query with an ID cursor and at most 101 returned rows. It adds
no count query, filesystem scan, provider call, or background worker.

## Risks

- Native `list_workspaces` now returns a page object instead of an
array. Individual summaries can be explicit previews when a single
record exceeds the byte budget. Its advertised descriptor and generated
contracts change together. Agents must follow `nextCursor` until null
for a complete inventory.
- Each page rechecks current visibility. Concurrent insertions,
deletions, or access changes require a fresh scan when a complete
point-in-time inventory is needed.
- No migration or journal-limit change. An existing cleanup quarantine
still requires normal recovery after deployment; this PR does not bypass
it.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, shell tools, code editing,
and test execution. The exact served model revision and context window
are not exposed in this session.

## 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
- [x] I have run tests locally and they pass
- [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
- [x] All Paperclip CI gates are green
- [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-11-05 13:24:52 -06:00

263 lines
24 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdtemp } from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { and, eq } from "drizzle-orm";
import { agents, authUsers, companyMemberships, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces } from "@paperclipai/db";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("task workspace API and native semantic authority", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); server = await startRunnerApiTestServer(); }, 60_000);
afterAll(async () => { await server?.close(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; });
it("prepares a repository through native JWT authority for the local board and rechecks revoked access", async () => {
const f = await server.fixture({ disableWakeOnDemand: true });
await server.db.insert(authUsers).values({ id: "local-board", name: "Local Board", email: "local-board@example.invalid",
emailVerified: true, createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: "local-board", membershipRole: "owner", status: "active" });
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId,
name: "Local task files", cwd: f.workspace, mode: "isolated_workspace", strategyType: "task_directory" }).returning();
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id,
responsibleUserId: "local-board" }).where(eq(issues.id, f.issueId));
await server.db.update(heartbeatRuns).set({ responsibleUserId: "local-board" }).where(eq(heartbeatRuns.id, f.runId));
await expect(f.authority.execute({ tool: "prepare_repository", callId: randomUUID(), arguments: {
repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "local-board-native" } }))
.resolves.toMatchObject({ kind: "requires_next_admission" });
await server.db.delete(companyMemberships).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, "local-board")));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, "local-board");
const denied = await fetch(`${server.apiUrl}/api/issues/${f.issueId}/workspace/repositories`, {
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ repository: { kind: "url", url: "https://github.com/public/other" }, requestKey: "different-sponsor" }),
});
expect([403, 404]).toContain(denied.status);
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(1);
});
it("creates an explicit-workspace child through the authenticated low-trust API without repeating its saved policy", async () => {
const f = await server.fixture({ connectionScenario: "fresh", disableWakeOnDemand: true });
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId, projectIds: [f.projectId] } } };
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: f.projectId,
projectWorkspaceId: f.projectWorkspaceId, sourceIssueId: f.issueId, name: "Review files", cwd: f.workspace,
mode: "isolated_workspace", strategyType: "task_directory" }).returning();
await server.db.update(projects).set({ executionWorkspacePolicy: policy }).where(eq(projects.id, f.projectId));
await server.db.update(issues).set({ executionPolicy: policy, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.userId);
const create = (parentId: string) => fetch(`${server.apiUrl}/api/companies/${f.companyId}/issues`, {
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ title: `Review child ${randomUUID()}`, parentId, projectId: f.projectId,
assigneeAgentId: f.agentId, status: "backlog", workspaceSelection: { kind: "existing", workspaceId: workspace.id } }),
});
const response = await create(f.issueId), body = await response.json();
expect({ status: response.status, body }).toMatchObject({ status: 201, body: {
parentId: f.issueId, executionWorkspaceId: workspace.id,
executionPolicy: { authorizationPolicy: { trustBoundary: policy.authorizationPolicy.trustBoundary } },
} });
const denied = await create(f.blockerId);
expect(denied.status).toBe(403);
});
it("inspects an idle admitted workspace, requests a repository, and stages root selection without interrupting", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: null, sourceIssueId: f.issueId, name: "Task files", cwd: f.workspace, mode: "shared_workspace", strategyType: "task_directory", status: "idle" }).returning();
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
const authority = new PaperclipRunnerToolAuthority(server.db, { companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, runId: f.runId, apiUrl: server.apiUrl, workspaceRoot: "/admitted/task" });
const call = (tool: string, args: Record<string, unknown>) => authority.execute({ tool, arguments: args, callId: randomUUID() });
const view = await call("get_workspace", {}) as { bindingRevision: number; cwd: string; workspace: { id: string } };
expect(view).toMatchObject({ cwd: "/admitted/task", workspace: { id: workspace.id } });
const args = { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "repository-one" };
const first = await call("prepare_repository", args) as { preparationId: string; kind: string };
expect(first.kind).toBe("requires_next_admission");
expect(await call("prepare_repository", args)).toMatchObject({ preparationId: first.preparationId });
const continued = await call("get_task_context", {}) as { completionContinuation: { kind: string; idempotencyKey: string } };
expect(continued.completionContinuation).toMatchObject({ kind: "same_agent" });
expect((await call("get_task_context", {}) as typeof continued).completionContinuation).toEqual(continued.completionContinuation);
await server.db.update(executionWorkspaceRepositories).set({ requestedByIssueId: f.blockerId }).where(eq(executionWorkspaceRepositories.id, first.preparationId));
expect(await call("get_task_context", {})).toMatchObject({ completionContinuation: null });
await server.db.update(executionWorkspaceRepositories).set({ requestedByIssueId: f.issueId, state: "ready" }).where(eq(executionWorkspaceRepositories.id, first.preparationId));
expect(await call("get_task_context", {})).toMatchObject({ completionContinuation: null });
expect(await call("select_workspace", { selection: { kind: "task_directory" }, expectedBindingRevision: view.bindingRevision, requestKey: "root-one" })).toMatchObject({ kind: "scheduled", applies: "next_normal_admission" });
const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
expect(task).toMatchObject({ executionWorkspaceId: workspace.id, projectId: null, workspacePendingSelection: { requestKey: "root-one" } });
const runs = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId));
expect(runs).toHaveLength(1);
expect(runs[0]).toMatchObject({ id: f.runId, status: "running" });
});
it("rejects repository preparation on an environment-owned workspace before queuing a receipt", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId,
name: "Plugin workspace", cwd: ".", mode: "adapter_managed", strategyType: "adapter_managed", providerType: "adapter_managed" }).returning();
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
await expect(call("get_workspace", {})).resolves.toMatchObject({ capabilities: { prepareRepository: "unsupported" } });
await expect(call("prepare_repository", { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "unsupported-plugin" }))
.rejects.toThrow("environment-owned workspace");
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id))).toEqual([]);
});
it("allows a projectless task to use a directory also configured by an unrelated protected project", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const [project] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Protected physical source",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await server.db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Protected folder", cwd: f.workspace });
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId,
name: "Projectless alias", cwd: f.workspace, mode: "shared_workspace", strategyType: "task_directory" }).returning();
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
const selection = { selection: { kind: "existing", workspaceId: workspace.id }, expectedBindingRevision: 0, requestKey: "protected-alias" };
const repository = { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "protected-root-write" };
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: workspace.id, projectId: null } });
await expect(call("select_workspace", selection)).resolves.toMatchObject({ kind: "scheduled" });
await expect(call("prepare_repository", repository)).resolves.toMatchObject({ kind: "requires_next_admission" });
});
it("denies new public clones for unlinked full-auto chat while keeping workspace discovery readable", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const cwd = await mkdtemp(join(dirname(f.workspace), "unlinked-task-"));
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId,
name: "Unlinked chat files", cwd, mode: "isolated_workspace", strategyType: "task_directory" }).returning();
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId } } };
await server.db.update(agents).set({ adapterConfig: { provider: "codex", codexPermissionMode: "never" } }).where(eq(agents.id, f.agentId));
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id,
executionPolicy: {} }).where(eq(issues.id, f.issueId));
await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: workspace.id } });
await expect(call("prepare_repository", { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "unlinked-full-auto" }))
.rejects.toThrow(/trusted task/);
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
});
it("does not expose repository preparation or workspace selection to Ask mode", async () => {
const f = await server.fixture({ mode: "ask", conversation: true, disableWakeOnDemand: true });
await expect(f.authority.execute({ tool: "prepare_repository", callId: randomUUID(), arguments: { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "denied" } })).rejects.toThrow();
await expect(f.authority.execute({ tool: "select_workspace", callId: randomUUID(), arguments: { selection: { kind: "task_directory" }, expectedBindingRevision: 0, requestKey: "denied" } })).rejects.toThrow();
});
it("lists shared and isolated task selections and excludes unavailable lifecycle states", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const definitions = [
{ name: "Shared active", mode: "shared_workspace", status: "active", closedAt: null },
{ name: "Shared idle", mode: "shared_workspace", status: "idle", closedAt: null },
{ name: "Isolated idle", mode: "isolated_workspace", status: "idle", closedAt: null },
{ name: "In review", mode: "isolated_workspace", status: "in_review", closedAt: null },
{ name: "Archived", mode: "shared_workspace", status: "archived", closedAt: null },
{ name: "Closed", mode: "shared_workspace", status: "active", closedAt: new Date() },
];
const rows = await server.db.insert(executionWorkspaces).values(definitions.map(definition => ({
...definition, companyId: f.companyId, projectId: null, strategyType: "task_directory", cwd: f.workspace,
}))).returning();
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
const { workspaces: listed } = await call("list_workspaces", {}) as { workspaces: Array<{ id: string }> };
const candidates = new Set(rows.slice(0, 3).map(row => row.id));
expect(listed.filter(row => rows.some(candidate => candidate.id === row.id)).map(row => row.id).sort()).toEqual([...candidates].sort());
for (const row of rows) {
const view = await call("get_workspace", {}) as { bindingRevision: number };
const selection = call("select_workspace", { selection: { kind: "existing", workspaceId: row.id },
expectedBindingRevision: view.bindingRevision, requestKey: `select-${row.id}` });
if (candidates.has(row.id)) await expect(selection).resolves.toMatchObject({ kind: "scheduled" });
else await expect(selection).rejects.toThrow("Workspace is unavailable or inaccessible");
}
});
it("pages a large authorized workspace inventory without exceeding the Runner command bound", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const timestamp = new Date("2026-01-01T00:00:00Z");
const rows = await server.db.insert(executionWorkspaces).values(Array.from({ length: 2_000 }, (_, index) => ({
companyId: f.companyId, name: `Workspace ${index}`, mode: "shared_workspace", strategyType: "task_directory",
cwd: `/tmp/${"repository/".repeat(20)}${index}`, lastUsedAt: timestamp, createdAt: timestamp,
}))).returning({ id: executionWorkspaces.id });
await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Archived", mode: "shared_workspace",
strategyType: "task_directory", cwd: f.workspace, status: "archived" });
const [privateTask] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private source",
visibility: "private", status: "backlog" }).returning({ id: issues.id });
await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: privateTask.id,
name: "Private workspace", mode: "shared_workspace", strategyType: "task_directory", cwd: f.workspace });
const foreign = await server.fixture({ conversation: true, disableWakeOnDemand: true });
await server.db.insert(executionWorkspaces).values({ companyId: foreign.companyId, name: "Foreign", mode: "shared_workspace",
strategyType: "task_directory", cwd: foreign.workspace });
type Page = { workspaces: Array<{ id: string }>; limit: number; returnedCount: number; hasMore: boolean; nextCursor: string | null };
const list = (args: Record<string, unknown>) => f.authority.execute({ tool: "list_workspaces", arguments: args, callId: randomUUID() }) as Promise<Page>;
let page = await list({});
expect(Buffer.byteLength(JSON.stringify(page))).toBeLessThan(128 * 1024);
expect(page).toMatchObject({ limit: 50, returnedCount: 50, hasMore: true });
const seen = page.workspaces.map(workspace => workspace.id);
const first = page;
expect(await list({})).toEqual(first);
while (page.nextCursor) {
const cursor = page.nextCursor;
page = await list({ cursor, limit: 100 });
expect(page.nextCursor === null || page.nextCursor > cursor).toBe(true);
expect(Buffer.byteLength(JSON.stringify(page))).toBeLessThan(128 * 1024);
expect(page.returnedCount).toBe(page.workspaces.length);
seen.push(...page.workspaces.map(workspace => workspace.id));
}
expect(page.hasMore).toBe(false);
expect(seen).toEqual(rows.map(row => row.id).sort());
expect(new Set(seen).size).toBe(rows.length);
await expect(list({ limit: 1_000 })).rejects.toThrow();
await expect(list({ cursor: "not-a-workspace-id" })).rejects.toThrow();
});
it("bounds encoded summary bytes and preserves discovery past oversized individual records", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const giantId = "00000000-0000-4000-8000-000000000001";
const giantName = "🦀\\\"".repeat(100_000);
const rows = await server.db.insert(executionWorkspaces).values([
{ id: giantId, companyId: f.companyId, name: giantName, mode: "shared_workspace", strategyType: "task_directory" },
...Array.from({ length: 60 }, () => ({ companyId: f.companyId, name: "🦀\\\"".repeat(4_000), mode: "shared_workspace", strategyType: "task_directory" })),
]).returning({ id: executionWorkspaces.id });
type Page = { workspaces: Array<{ id: string; name: string; truncatedFields?: string[] }>; hasMore: boolean; nextCursor: string | null };
const list = (cursor?: string) => f.authority.execute({ tool: "list_workspaces", arguments: { limit: 100, ...(cursor ? { cursor } : {}) }, callId: randomUUID() }) as Promise<Page>;
let page = await list();
expect(page).toMatchObject({ nextCursor: giantId, workspaces: [{ id: giantId, truncatedFields: ["name"] }] });
const seen: string[] = [];
for (;;) {
expect(Buffer.byteLength(JSON.stringify(page))).toBeLessThanOrEqual(128 * 1024);
seen.push(...page.workspaces.map(row => row.id));
if (!page.nextCursor) break;
const cursor = page.nextCursor;
page = await list(cursor);
expect(page.nextCursor === null || page.nextCursor > cursor).toBe(true);
expect(page.workspaces.every(row => row.name === "🦀\\\"".repeat(4_000))).toBe(true);
}
expect(seen).toEqual(rows.map(row => row.id).sort());
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.userId);
const detail = await fetch(`${server.apiUrl}/api/execution-workspaces/${giantId}`, { headers: { authorization: `Bearer ${token}` } });
expect(detail.status).toBe(200);
expect((await detail.json()).name).toBe(giantName);
});
it("keeps source authorization on projectless task workspace inspection, selection, and preparation", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, f.issueId));
const [source] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId,
projectId: f.projectId, projectWorkspaceId: f.projectWorkspaceId, mode: "shared_workspace", strategyType: "project_primary", cwd: f.workspace, name: "Source project" }).returning();
const [unrelated] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId,
projectId: null, sourceIssueId: f.blockerId, mode: "shared_workspace", strategyType: "task_directory", cwd: f.workspace, name: "Unrelated task files" }).returning();
await server.db.update(issues).set({ projectId: null }).where(eq(issues.id, f.blockerId));
const boundary = { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId };
const policy = (projectIds: string[]) => ({ trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { ...boundary, projectIds } } });
await server.db.update(agents).set({ permissions: policy([]) }).where(eq(agents.id, f.agentId));
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
await expect(call("list_workspaces", {})).rejects.toThrow(/outside.*boundary|company-wide|privileged/i);
for (const selection of [{ kind: "existing", workspaceId: source.id },
{ kind: "configured_source", projectWorkspaceId: f.projectWorkspaceId, mode: "shared" },
{ kind: "existing", workspaceId: unrelated.id }]) {
await expect(call("select_workspace", { selection, expectedBindingRevision: 0, requestKey: randomUUID() })).rejects.toThrow(/outside.*boundary/i);
}
await server.db.update(issues).set({ executionWorkspaceId: source.id }).where(eq(issues.id, f.issueId));
await expect(call("get_workspace", {})).rejects.toThrow(/outside.*boundary/i);
const repository = { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "source-denied" };
await expect(call("prepare_repository", repository)).rejects.toThrow(/outside.*boundary/i);
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
await server.db.update(agents).set({ permissions: policy([f.projectId]) }).where(eq(agents.id, f.agentId));
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: source.id, projectId: f.projectId } });
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).resolves.toMatchObject({ kind: "scheduled" });
await expect(call("prepare_repository", repository)).rejects.toThrow(/trusted task/);
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
expect((await server.db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, unrelated.id)))[0]?.cwd).toBe(f.workspace);
expect((await server.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]?.projectId).toBeNull();
});
});