mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents split work into child tasks, and each task runs in an execution workspace (a checkout, branch, or sandbox folder) > - Since early 2026, a new child task used its parent's realized workspace by default: the same checkout, branch and sandbox > - A parent and its child often run at the same time. Two writers in one folder lose each other's files, the waking parent is cancelled with `workspace_busy`, and sandbox lease ownership crosses from the parent into the child, so retained-file recovery refuses the workspace as ambiguous > - The product rule is simple: a subtask gets its own workspace, independent of the parent's saved files. Paperclip never needs to carry a parent's unmerged changes into a child > - This pull request makes that the only behaviour on every creation path, and moves existing shared children to their own workspace on their next run, with no user or admin action > - The benefit is that parents and children never block or overwrite each other, and recovery ownership stays with one task ## Linked Issues or Issue Description **What happened?** A child task inherited its parent's realized execution workspace by default (`createChild` defaulted to `executionWorkspaceInheritanceMode: "linkage"`, and `issueService.create` fell back from `inheritExecutionWorkspaceFromIssueId` to `parentId`). When the parent woke while the child wrote to the shared workspace, the parent run was cancelled with `workspace_busy`. In sandbox environments, the lease ownership chain crossed from the parent into the child, and retained-file recovery refused the workspace with `allocation_ownership_ambiguous`. **Expected behavior** Every new child task gets its own execution workspace, realized from the project base exactly as a top-level task in that project. It never uses the parent's checkout, branch, saved snapshot or unmerged changes. A parent and its descendant never share a workspace by any route. **Steps to reproduce** 1. Run a parent task in a project with isolated (git worktree) or sandbox workspaces, so the parent has a realized execution workspace. 2. From the parent run, create a child task with only `parentId` (or through the runner `create_task` tool, chat suggestions, decision effects, plugins, or the New sub-task dialog). 3. See that the child's `executionWorkspaceId` is the parent's, with `executionWorkspacePreference: "reuse_existing"`. 4. Wake the parent while the child runs in a sandbox environment. The parent run is cancelled with `workspace_busy`. **Additional context** Refs #9233 (the existing `strategy_only` opt-out). The opt-out `executionWorkspaceInheritanceMode: "strategy_only"` already existed for accepted-plan decomposition. This change makes that behaviour (tightened to drop a pinned existing branch) the only behaviour. ## What Changed - **One rule for every creation path (`server/src/services/issues.ts`).** `issueService.create` still uses the parent (or `inheritExecutionWorkspaceFromIssueId`) as a source for the project, project workspace and workspace *strategy*, but it never copies the source's `executionWorkspaceId`, `reuse_existing` preference, or pinned existing branch. All server paths that create a task go through `create()`/`createChild()`: issue routes (`POST /issues`, `POST /issues/:id/children`, the run-bound default parent and run workspace source), runner `create_task`, chat suggestion acceptance, native status decisions, accepted-plan decomposition, decision effects, plugins (`ctx.issues.create`), external agents, watchdog follow-ups, monitor recovery issues, case handoffs and email channels. - **Explicit ancestor workspaces are dropped.** If a create names an `executionWorkspaceId` (or an `existing` workspace selection) that an ancestor of the new task created or is bound to, `create()` drops it and keeps only the strategy. Older clients and skills that copied the parent's id keep working and get an independent workspace. - **`createChild` no longer has a "linkage" mode.** `executionWorkspaceInheritanceMode` is still accepted from internal callers and is ignored. - **`inheritExecutionWorkspaceFromIssueId` decision.** I kept the field for API compatibility, but it now copies only the source issue's project and workspace strategy. I removed its workspace-sharing behaviour for every pair, not only parent/child: the source task and its follow-up can run at the same time (the runner, watchdog and status-decision callers all create the follow-up while the source run is live), so sharing would again let two tasks write one folder. Explicit sharing between unrelated tasks stays available through an explicit `existing` workspace selection. - **New `server/src/services/task-workspace-lineage.ts`.** `buildIndependentTaskWorkspaceSettings` (strategy only: mode, environment, runtime, network egress, shared-workspace concurrency, base ref, branch template, commands; no `reuse_existing`, no `operator_branch`, no `existingBranch`), `loadTaskLineageIssueIds` (the parent chain plus, for a task that depth placement put beside its requester, the requester's chain via `privacyParentIssueId`) and `isAncestorExecutionWorkspace` (the workspace's `sourceIssueId` is in that lineage, or a lineage issue is bound to it). A child in another project does not take the parent's strategy. - **Upgrade-safe rebind at admission.** `executionWorkspaceService.releaseAncestorWorkspaceBindingAtAdmission` runs at the same admission point as the pending workspace selection (`heartbeat.ts`). For a child (or a flattened task beside its requester) that an older release bound to an ancestor's workspace, it clears the child's binding (keeps the project workspace and strategy, bumps the binding revision) so the run realizes the child's own workspace. It does nothing while another run of the child is running, while the child's earlier run still owns workspace finalization or recovery, for native restart/lease-owner recovery, for a process-loss retry, or for a Retry that keeps the current workspace. The parent and its workspace are never changed. - **Projects without a project workspace (per-task repository acquisition).** A parent with no project workspace worked in its own task files, so its settings (for example `isolated_workspace`) do not describe the project base. A child of such a parent, and an older child released from one, takes the project's defaults exactly like a top-level task, instead of an isolated mode that would resolve to a git worktree with no git base ("needs a project / project workspace"). When the child's new workspace is created at admission, `executionWorkspaceRepositoryService.prepareForAdmission` repeats the parent's (or requester's) repository requests as the child's own pending requests (identity, URL, catalog id and requested ref only; no pinned commit, branch or files). The normal acquisition path then clones a fresh copy into the child's task files with the child's own access checks. This runs only for a workspace created by that admission, so existing tasks never gain repositories. - **UI.** The New sub-task dialog no longer pre-selects the parent's workspace. `buildSubIssueDefaults` drops `executionWorkspaceId` and `reuse_existing`, and derives the parent's independent mode (`shared_workspace`, `isolated_workspace` or `agent_default`) from its preference, saved settings or current workspace. The "This sub-task will no longer use the parent task workspace" warning is removed. - **Contracts and docs.** Shared validator and plugin SDK comments describe the new meaning of `inheritExecutionWorkspaceFromIssueId`. Updated `doc/execution-semantics.md` (new rule in §6 and a wording fix in the retained-sandbox paragraph), `doc/SPEC-implementation.md` (child workspace rule, requester keeps *strategy* inheritance, watchdog follow-ups), `doc/SPEC.md` (task workspace ownership), `docs/specs/external-task-protocol.md`, `skills/paperclip/SKILL.md` (step 9 and the workspace bullet), `skills/paperclip/references/issue-workspaces.md`, the CEO onboarding `HEARTBEAT.md`, and the plugin SDK README. ### Previous-release story (`rules/upgrade-safe-run-state.mdc`) - **Migrated on next admission.** A child that the previous release bound to its parent's (or any ancestor's) execution workspace keeps that binding until its next normal admission. Then the server clears the binding and the run realizes the child's own workspace from the project base. No schema migration is needed. No user or admin action is needed. - **In-flight writers finish first.** A running child is not interrupted. Its current run, its native restart recovery (admitted native input or lease owner), a process-loss retry, and a Retry that keeps the current workspace all finish in the shared workspace. While a child's earlier run is still running or its workspace finalization or recovery is unsettled, the binding stays and a later admission releases it. - **No files are dropped.** The release changes only the child's binding row. The parent keeps its binding, and the shared workspace row, folder, branch and saved snapshots are not touched. Anything the child left there stays in the parent's workspace. - **No new refusal codes.** Saved run identity (native execution input, leases, checkpoints) is not changed or refused; the rebind only affects new admissions. ## Verification - New `server/src/__tests__/subtask-workspace-independence.test.ts` (embedded Postgres): `create` with `parentId`, `createChild` with no mode / `"linkage"` / `"strategy_only"`, `inheritExecutionWorkspaceFromIssueId` pointing at the parent, explicit parent and grandparent `executionWorkspaceId`, a non-child follow-up, and unchanged top-level defaults and explicit top-level reuse. 7 creation cases fail on `master` and pass with this change. Admission cases: an old-release child is rebound while its parent is running and the parent keeps its workspace; a running child is not rebound (deploy lands mid-run); an unrelated explicit share is kept; the parent is never rebound. - New heartbeat case in `server/src/__tests__/heartbeat-workspace-busy.test.ts`: an old-release child bound to its running parent's workspace in a sandbox environment runs to `succeeded` with no `workspace_busy`, gets a new workspace, and the parent run and binding stay unchanged. It fails on `master` (the child waits with `workspace_busy`). - Updated tests that asserted the old inheritance in `issues-service.test.ts`, `task-workspace-binding.test.ts`, `chat-project-tools.test.ts`, `task-workspace-child-trust.test.ts`, `heartbeat-git-restore-point-resume.test.ts` (a child bound to a closed parent's expired workspace now starts from the project base and leaves the parent's restore point alone), and `ui/src/lib/subIssueDefaults.test.ts`. Remote-workspace reservation, continuation and writer-wait suites now seed the older-release shared shape directly (or an unrelated explicit share), so those safety paths stay covered. - `npx vitest run` on the changed suites and about 45 related server suites (heartbeat workspace, recovery admission, issue routes, routines, plugins), `ui` `subIssueDefaults` and `NewIssueDialog` tests, `pnpm check:token-gates`, `pnpm -r typecheck`. - New `server/src/__tests__/heartbeat-subtask-acquired-repository.test.ts`: a project with no project workspace, a parent whose task files hold an acquired repository with an unmerged change, and a child (new, and the older-release shape bound to the parent's workspace) that runs to `succeeded` in its own task files with a fresh clone at the project base and its own ready repository receipt. The parent's checkout keeps its change. Both cases fail on the previous head with "needs a project / project workspace". ## Risks - **Project-level configuration still applies to children exactly as to top-level tasks.** In a project configured for `shared_workspace`, every task (top-level or child) uses the project's one folder under the existing shared-workspace concurrency policy. A branch that the project itself pins applies to every task in the project. This PR removes only the parent-to-child carry-over of the realized workspace; it does not change those project defaults. - **Standalone follow-ups that an older release bound through `inheritExecutionWorkspaceFromIssueId`** are migrated only when the source is in their recorded lineage (parent or privacy parent). Others keep their explicit share and the existing writer-wait serialization. - **Behaviour change for agents that relied on a shared checkout.** A child or follow-up no longer sees the parent's unpushed or uncommitted changes. Agents must push or merge code they want a subtask to build on. The skill and onboarding text now say this. - **Explicit ancestor workspace ids are silently dropped** instead of rejected, so older clients do not break. The task gets an independent workspace. - **First run of a migrated child starts from the project base.** If an older child had uncommitted work in the parent's workspace, that work stays in the parent's workspace and is not copied into the child's new workspace. - **Admission adds one indexed read** for tasks with a parent and a bound workspace; the locking transaction and ancestor query run only when the bound workspace was created by another issue. ## Model Used - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through Claude Code with tool use (file edits, shell, test execution) and extended reasoning. ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
24 KiB
TypeScript
265 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();
|
|
// The selection passes the trust checks, but the workspace is the parent's,
|
|
// so the child gets its own workspace instead of sharing it.
|
|
expect({ status: response.status, body }).toMatchObject({ status: 201, body: {
|
|
parentId: f.issueId, executionWorkspaceId: null,
|
|
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();
|
|
});
|
|
});
|