mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People ask a chat agent to take them to a project or to the files of a task > - The agent finds the right project or workspace, but it cannot link the page > - The `list_projects` and `list_workspaces` tool results have no page route, and the chat link guidance names no project or workspace format > - The agent then writes a bare path or a shortened directory that the person cannot open > - This pull request adds the page `href` to these tool results and adds the project link format to the chat guidance > - The benefit is that chat replies give a clickable link to the real project or workspace page ## Linked Issues or Issue Description **What happened** In chat, a person asked "Take me to the Launch project and show its open tasks." The agent found the project. Its reasoning said "There's no project page route documented explicitly". The reply wrote the project as a bare path `/COM/projects/<id>`, which is not clickable. In a second chat, a person asked where the files of a task are. The agent named the correct workspace but gave a shortened directory and no link. **Expected behavior** The reply links the project page (`/<prefix>/projects/<id>`) and the task's workspace page (`/<prefix>/execution-workspaces/<id>`) as Markdown links, so the person can open them. **Steps to reproduce** 1. Create a company with a project and a task that has a saved execution workspace. Enable isolated workspaces. 2. In chat, ask the agent to take you to the project, or to say where the task's files are. 3. Look at the reply. It has no project or workspace link. Related: Refs #17985 (an earlier open PR that adds project and goal link wording for newly created projects only). This PR covers project discovery and workspaces and adds tool-result hrefs. ## What Changed - `list_projects` rows now carry `href: /<prefix>/projects/<id>`. Both the native runner tool authority and the MCP project-tools route pass the company prefix. - `get_workspace` and `list_workspaces` now carry `href: /<prefix>/execution-workspaces/<id>`, only while isolated workspaces are enabled. The workspace route redirects to the dashboard when the feature is off, so no href is offered then. - The chat link guidance (`AGENT_CHAT_DIRECTIVE`) adds the project link format. It tells the agent to link a returned `href`, and to name a workspace that has no `href` instead of linking it. - Regression tests: guidance text against the registered UI route, `callProjectTool` href, and an embedded-Postgres authority test for project hrefs and the workspace flag on/off. ## Verification - Red then green: the three new tests fail on `origin/master` source and pass with this change. - `npx vitest run` in `server/` for: `agent-conversations`, `task-workspace-api`, `project-tools-pagination`, `task-workspace-tools`, `chat-project-tools`, `dot-runner`, `heartbeat-list` (prompt-size limits), `task-markdown`, `muse-native-visibility`, `paperclip-runner-tool-authority`, `runner-api`, `codex-local-execute`, `board-access-denial`. All pass. - `npx tsc --noEmit -p server` passes. - Live chat eval: see the comment on this PR for the paid runner E2E run of `navigation-preferences.find-project-view` and `workspaces.locate-files` with this change. ## Risks - Low risk. The change adds one optional field to read-only tool results. The output schemas allow extra fields. - The guidance grows by two sentences. The prompt-size test in `heartbeat-list.test.ts` passes. - `list_projects` and the workspace tools now do one extra small read (company prefix, and the instance experimental settings for workspaces). ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), agentic coding 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 - [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 - [ ] All Paperclip CI gates are green - [ ] 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>
287 lines
26 KiB
TypeScript
287 lines
26 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, companies, 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";
|
|
import { instanceSettingsService } from "../services/instance-settings.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();
|
|
});
|
|
it("returns real page hrefs for projects and, only while their pages are routed, for workspaces", async () => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
const [{ issuePrefix }] = await server.db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, f.companyId));
|
|
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId, projectId: null,
|
|
name: "Website files", 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) => f.authority.execute({ tool, arguments: {}, callId: randomUUID() }) as Promise<any>;
|
|
const projects = (await call("list_projects")).projects as Array<{ id: string; href?: string }>;
|
|
expect(projects.find(row => row.id === f.projectId)?.href).toBe(`/${issuePrefix}/projects/${f.projectId}`);
|
|
const settings = instanceSettingsService(server.db);
|
|
const workspaceHrefs = async () => [(await call("get_workspace")).workspace?.href,
|
|
((await call("list_workspaces")).workspaces as Array<{ id: string; href?: string }>).find(row => row.id === workspace.id)?.href];
|
|
try {
|
|
await settings.updateExperimental({ enableIsolatedWorkspaces: false });
|
|
// The detail route redirects to the dashboard while the feature is off, so no href is offered.
|
|
expect(await workspaceHrefs()).toEqual([undefined, undefined]);
|
|
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
|
|
const href = `/${issuePrefix}/execution-workspaces/${workspace.id}`;
|
|
expect(await workspaceHrefs()).toEqual([href, href]);
|
|
} finally { await settings.updateExperimental({ enableIsolatedWorkspaces: false }); }
|
|
});
|
|
});
|