mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Issue execution uses isolated workspaces that hold the issue
worktree
> - A terminal issue can leave its isolated workspace archived and
unable to resume
> - The existing closed-workspace guards returned a conflict and gave
the user no self-serve recovery
> - This pull request reopens the same isolated workspace row and
rebuilds its worktree
> - The benefit is that resume, checkout, and comment actions can
continue without a new workspace row
## Linked Issues or Issue Description
**Problem or motivation**
A terminal issue can point to an archived isolated execution workspace.
Resume, checkout, and comment actions then stop with a conflict.
**What happened?**
A terminal issue kept its issue-to-workspace link after the isolated
workspace reached a closed status. The guarded actions returned HTTP 409
instead of restoring access.
**Expected behavior**
The next authorized resume, checkout, or comment action reopens the same
isolated workspace row. The action rebuilds the worktree and then
continues.
**Steps to reproduce**
1. Create an issue that uses an isolated execution workspace.
2. Move the issue to a terminal state and let the workspace archive.
3. Try to resume the issue or add a comment.
4. Observe the closed-workspace conflict.
**Paperclip version or commit**
e6e79f458e
**Deployment mode**
Built from source with pnpm.
**Proposed solution**
Reopen the closed isolated workspace in place. Rebuild the worktree
before the route reports success.
**Alternatives considered**
Create a new workspace row. This would require repointing the issue link
and would not preserve access for issues that share the original row.
**Roadmap alignment**
ROADMAP.md has no matching reopen item.
## What Changed
- Reopen closed isolated workspace rows in place and rebuild their
worktrees.
- Use the reopen path from resume, checkout, and comment guards.
- Return a clear error when the rebuild fails and keep the workspace
closed.
- Fence terminal reaping and archive cleanup while a reopen is in
flight.
- Update the comment composer to allow the next action to reopen the
workspace.
- Add service and route tests for reopen, scope, failure, and lifecycle
races.
## Verification
- Server TypeScript check passes with the tsc --noEmit command.
- UI TypeScript check passes with the tsc --noEmit command.
- Seventy-two server tests pass across the affected test files.
- The isolated worktree UI test suite has a dependency mismatch and
fails before tests run with the error TypeError: act is not a function.
- CI confirms the UI test job after a clean dependency install.
## Risks
The reopen path changes behavior for closed isolated workspaces. A
rebuild failure returns an error and keeps the row closed. Lifecycle
locks and generation checks protect the worktree from stale cleanup. The
UI test mismatch needs CI confirmation.
## Model Used
OpenAI Codex, GPT-5, with tool use and code review assistance. The
runtime did not provide the context window size.
## 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 references)
- [x] My branch name describes the change 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>
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockIssueService = vi.hoisted(() => ({
|
|
addComment: vi.fn(),
|
|
assertCheckoutOwner: vi.fn(),
|
|
create: vi.fn(),
|
|
findMentionedAgents: vi.fn(),
|
|
getByIdentifier: vi.fn(),
|
|
getById: vi.fn(),
|
|
getRelationSummaries: vi.fn(),
|
|
getWakeableParentAfterChildCompletion: vi.fn(),
|
|
listWakeableBlockedDependents: vi.fn(),
|
|
update: vi.fn(),
|
|
}));
|
|
|
|
const mockAccessService = vi.hoisted(() => ({
|
|
canUser: vi.fn(),
|
|
decide: vi.fn(),
|
|
hasPermission: vi.fn(),
|
|
}));
|
|
|
|
const mockAgentService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
}));
|
|
|
|
const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
}));
|
|
|
|
const mockFeedbackService = vi.hoisted(() => ({
|
|
listIssueVotesForUser: vi.fn(),
|
|
saveIssueVote: vi.fn(),
|
|
}));
|
|
|
|
const mockHeartbeatService = vi.hoisted(() => ({
|
|
wakeup: vi.fn(),
|
|
reportRunActivity: vi.fn(),
|
|
getRun: vi.fn(),
|
|
getActiveRunForAgent: vi.fn(),
|
|
cancelRun: vi.fn(),
|
|
}));
|
|
|
|
const mockInstanceSettingsService = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
listCompanyIds: vi.fn(),
|
|
}));
|
|
|
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
|
|
|
const mockRoutineService = vi.hoisted(() => ({
|
|
syncRunStatusForIssue: vi.fn(),
|
|
}));
|
|
|
|
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({
|
|
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
|
|
Promise.resolve([{ companyId: "company-1", agentId: "agent-1", contextSnapshot: null }]).then(
|
|
onFulfilled,
|
|
onRejected,
|
|
),
|
|
})));
|
|
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
|
|
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
|
|
const mockDb = vi.hoisted(() => ({
|
|
select: mockDbSelect,
|
|
}));
|
|
|
|
function registerRouteMocks() {
|
|
vi.doMock("../services/access.js", () => ({
|
|
accessService: () => mockAccessService,
|
|
}));
|
|
|
|
vi.doMock("../services/activity-log.js", () => ({
|
|
logActivity: mockLogActivity,
|
|
}));
|
|
|
|
vi.doMock("../services/agents.js", () => ({
|
|
agentService: () => mockAgentService,
|
|
}));
|
|
|
|
vi.doMock("../services/execution-workspaces.js", () => ({
|
|
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
|
STALE_REOPEN_PENDING_CONSUMPTION_GRACE_MS: 5 * 60 * 1000,
|
|
}));
|
|
|
|
vi.doMock("../services/feedback.js", () => ({
|
|
feedbackService: () => mockFeedbackService,
|
|
}));
|
|
|
|
vi.doMock("../services/heartbeat.js", () => ({
|
|
heartbeatService: () => mockHeartbeatService,
|
|
}));
|
|
|
|
vi.doMock("../services/instance-settings.js", () => ({
|
|
instanceSettingsService: () => mockInstanceSettingsService,
|
|
}));
|
|
|
|
vi.doMock("../services/issues.js", () => ({
|
|
issueService: () => mockIssueService,
|
|
}));
|
|
|
|
vi.doMock("../services/routines.js", () => ({
|
|
routineService: () => mockRoutineService,
|
|
}));
|
|
|
|
vi.doMock("../services/index.js", () => ({
|
|
companyService: () => ({
|
|
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
|
}),
|
|
accessService: () => mockAccessService,
|
|
agentService: () => mockAgentService,
|
|
companySkillService: () => ({
|
|
completeTestRunForIssue: vi.fn(async () => null),
|
|
}),
|
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
|
documentService: () => ({}),
|
|
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
|
feedbackService: () => mockFeedbackService,
|
|
goalService: () => ({}),
|
|
heartbeatService: () => mockHeartbeatService,
|
|
instanceSettingsService: () => mockInstanceSettingsService,
|
|
issueApprovalService: () => ({}),
|
|
issueReferenceService: () => ({
|
|
deleteDocumentSource: async () => undefined,
|
|
diffIssueReferenceSummary: () => ({
|
|
addedReferencedIssues: [],
|
|
removedReferencedIssues: [],
|
|
currentReferencedIssues: [],
|
|
}),
|
|
emptySummary: () => ({ outbound: [], inbound: [] }),
|
|
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
|
syncComment: async () => undefined,
|
|
syncDocument: async () => undefined,
|
|
syncIssue: async () => undefined,
|
|
}),
|
|
issueRecoveryActionService: () => ({
|
|
getActiveForIssue: vi.fn(async () => null),
|
|
listActiveForIssues: vi.fn(async () => new Map()),
|
|
}),
|
|
issueThreadInteractionService: () => ({
|
|
listForIssue: vi.fn(async () => []),
|
|
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
|
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
|
}),
|
|
issueService: () => mockIssueService,
|
|
logActivity: mockLogActivity,
|
|
projectService: () => ({}),
|
|
routineService: () => mockRoutineService,
|
|
workProductService: () => ({}),
|
|
}));
|
|
}
|
|
|
|
async function createApp(actor: Record<string, unknown>) {
|
|
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
|
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
|
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
|
]);
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = actor;
|
|
next();
|
|
});
|
|
app.use("/api", issueRoutes(mockDb as any, {} as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
function makeIssue(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "issue-1",
|
|
companyId: "company-1",
|
|
status: "todo",
|
|
priority: "medium",
|
|
projectId: null,
|
|
goalId: null,
|
|
parentId: null,
|
|
assigneeAgentId: null,
|
|
assigneeUserId: null,
|
|
createdByUserId: "board-user",
|
|
identifier: "PAP-1000",
|
|
title: "Workspace authz",
|
|
executionPolicy: null,
|
|
executionState: null,
|
|
executionWorkspaceId: null,
|
|
hiddenAt: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("issue workspace command authorization", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.doUnmock("../services/access.js");
|
|
vi.doUnmock("../services/activity-log.js");
|
|
vi.doUnmock("../services/agents.js");
|
|
vi.doUnmock("../services/execution-workspaces.js");
|
|
vi.doUnmock("../services/feedback.js");
|
|
vi.doUnmock("../services/heartbeat.js");
|
|
vi.doUnmock("../services/index.js");
|
|
vi.doUnmock("../services/instance-settings.js");
|
|
vi.doUnmock("../services/issues.js");
|
|
vi.doUnmock("../services/routines.js");
|
|
vi.doUnmock("../routes/issues.js");
|
|
vi.doUnmock("../routes/authz.js");
|
|
vi.doUnmock("../middleware/index.js");
|
|
registerRouteMocks();
|
|
vi.clearAllMocks();
|
|
mockIssueService.addComment.mockResolvedValue(null);
|
|
mockIssueService.create.mockResolvedValue(makeIssue());
|
|
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
|
mockIssueService.getById.mockResolvedValue(makeIssue());
|
|
mockIssueService.getByIdentifier.mockResolvedValue(null);
|
|
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
|
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
|
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
|
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
|
mockIssueService.update.mockResolvedValue(makeIssue());
|
|
mockAccessService.canUser.mockResolvedValue(true);
|
|
mockAccessService.decide.mockResolvedValue({
|
|
allowed: true,
|
|
action: "company_scope:read",
|
|
reason: "allow_test",
|
|
explanation: "Allowed by test mock.",
|
|
});
|
|
mockAccessService.hasPermission.mockResolvedValue(true);
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
permissions: null,
|
|
});
|
|
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
|
|
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
|
|
mockFeedbackService.saveIssueVote.mockResolvedValue({
|
|
vote: null,
|
|
consentEnabledNow: false,
|
|
sharingEnabled: false,
|
|
});
|
|
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
|
|
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
|
|
mockHeartbeatService.getRun.mockResolvedValue(null);
|
|
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
|
|
mockHeartbeatService.cancelRun.mockResolvedValue(null);
|
|
mockInstanceSettingsService.get.mockResolvedValue({
|
|
id: "instance-settings-1",
|
|
general: {
|
|
censorUsernameInLogs: false,
|
|
feedbackDataSharingPreference: "prompt",
|
|
},
|
|
});
|
|
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
|
|
mockLogActivity.mockResolvedValue(undefined);
|
|
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
|
|
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
|
|
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
|
|
mockDbSelectWhere.mockImplementation(() => ({
|
|
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
|
|
Promise.resolve([{ companyId: "company-1", agentId: "agent-1", contextSnapshot: null }]).then(
|
|
onFulfilled,
|
|
onRejected,
|
|
),
|
|
}));
|
|
});
|
|
|
|
it("rejects agent callers that create issue workspace runtime provision commands", async () => {
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/issues")
|
|
.send({
|
|
title: "Exploit",
|
|
executionWorkspaceSettings: {
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
runtimeProvisionCommand: "touch /tmp/paperclip-rce",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed workspace commands");
|
|
expect(mockIssueService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects agent callers that patch assignee adapter workspace teardown commands", async () => {
|
|
mockIssueService.getById.mockResolvedValue(makeIssue());
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch("/api/issues/issue-1")
|
|
.send({
|
|
assigneeAdapterOverrides: {
|
|
adapterConfig: {
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
teardownCommand: "rm -rf /tmp/paperclip-rce",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("host-executed workspace commands");
|
|
expect(mockIssueService.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|