Merge upstream/master into dev (76 commits)

Resolved 5 conflicts:
- .github/workflows/docker.yml, release.yml: kept fork stubs (CI handled by build-prod/build-dev)
- server/src/routes/secrets.ts: kept fork's /usages route alongside upstream's /usage, /access-events
- server/src/services/secrets.ts: kept fork's usages() function and in-use deletion guard,
  layered before upstream's soft-delete + provider cleanup in remove()
- ui/src/api/secrets.ts: kept fork's usages() method alongside upstream's vault methods

Typechecks pass on @paperclipai/shared, @paperclipai/server, @paperclipai/ui.
This commit is contained in:
2026-05-11 18:01:34 -04:00
625 changed files with 145314 additions and 4442 deletions
+169 -4
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -215,6 +215,103 @@ describe("acpx_local execute", () => {
}
});
it("closes successful persistent runs by default while retaining session state", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-close-success-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
const execute = createAcpxLocalExecutor({
createRuntime: () => runtime,
});
const result = await execute(buildContext(root));
expect(result.exitCode).toBe(0);
expect(result.sessionParams).toMatchObject({
mode: "persistent",
acpSessionId: "acp-1",
});
expect(runtime.closeInputs).toEqual([
expect.objectContaining({
reason: "paperclip completed turn cleanup",
discardPersistentState: false,
}),
]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("applies requested Codex model, reasoning effort, and fast mode before starting the turn", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-codex-config-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
const execute = createAcpxLocalExecutor({
createRuntime: () => runtime,
});
const result = await execute(buildContext(root, {
config: {
agent: "codex",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
model: "gpt-5.4",
modelReasoningEffort: "xhigh",
fastMode: true,
},
}));
expect(result.exitCode).toBe(0);
expect(result.model).toBe("gpt-5.4");
expect(runtime.setConfigInputs).toEqual([
expect.objectContaining({ key: "model", value: "gpt-5.4" }),
expect.objectContaining({ key: "reasoning_effort", value: "xhigh" }),
expect.objectContaining({ key: "service_tier", value: "fast" }),
expect.objectContaining({ key: "features.fast_mode", value: "true" }),
]);
expect(runtime.startInputs).toHaveLength(1);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("logs a clear error when configured session options need unsupported runtime controls", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-missing-config-controls-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
Object.defineProperty(runtime, "setConfigOption", { value: undefined });
const logs: LogEntry[] = [];
const execute = createAcpxLocalExecutor({
createRuntime: () => runtime,
});
const result = await execute(buildContext(root, {
config: {
agent: "codex",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
model: "gpt-5.4",
},
onLog: async (stream, chunk) => logs.push({ stream, chunk }),
}));
expect(result.exitCode).toBe(1);
expect(result.errorMessage).toContain("does not expose session config controls");
expect(logs).toEqual(expect.arrayContaining([
expect.objectContaining({
stream: "stderr",
chunk: expect.stringContaining("upgrade ACPX or remove configured model"),
}),
]));
expect(runtime.closeInputs).toEqual([
expect.objectContaining({
reason: "paperclip config cleanup",
discardPersistentState: false,
}),
]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("reuses a compatible warm session and starts fresh when cwd changes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-reuse-"));
const other = path.join(root, "other");
@@ -228,8 +325,15 @@ describe("acpx_local execute", () => {
return runtime;
},
});
const warmConfig = {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
};
const first = await execute(buildContext(root));
const first = await execute(buildContext(root, { config: warmConfig }));
const second = await execute(buildContext(root, {
runtime: {
sessionId: first.sessionId ?? null,
@@ -237,6 +341,7 @@ describe("acpx_local execute", () => {
sessionDisplayId: first.sessionDisplayId ?? null,
taskKey: "PAP-1",
},
config: warmConfig,
}));
const third = await execute(buildContext(root, {
runtime: {
@@ -250,6 +355,7 @@ describe("acpx_local execute", () => {
cwd: other,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
},
}));
@@ -279,8 +385,26 @@ describe("acpx_local execute", () => {
});
const [first, second] = await Promise.all([
execute(buildContext(root, { runId: "run-1" })),
execute(buildContext(root, { runId: "run-2" })),
execute(buildContext(root, {
runId: "run-1",
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
},
})),
execute(buildContext(root, {
runId: "run-2",
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
},
})),
]);
expect(first.exitCode).toBe(0);
@@ -295,6 +419,47 @@ describe("acpx_local execute", () => {
}
});
it("cleans configured warm handles after their idle window", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-warm-idle-"));
vi.useFakeTimers();
try {
let clock = 0;
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
const warmHandles = new Map();
const execute = createAcpxLocalExecutor({
warmHandles,
now: () => clock,
createRuntime: () => runtime,
});
const result = await execute(buildContext(root, {
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 1_000,
},
}));
expect(result.exitCode).toBe(0);
expect(warmHandles.size).toBe(1);
clock = 1_000;
await vi.advanceTimersByTimeAsync(1_000);
expect(warmHandles.size).toBe(0);
expect(runtime.closeInputs).toEqual([
expect.objectContaining({
reason: "paperclip idle cleanup",
discardPersistentState: false,
}),
]);
} finally {
vi.useRealTimers();
await fs.rm(root, { recursive: true, force: true });
}
});
it("retries with a fresh session when ACPX cannot resume the saved backend session", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-resume-"));
try {
+3 -3
View File
@@ -128,7 +128,7 @@ describe.sequential("activity routes", () => {
});
});
it("resolves issue identifiers before loading runs", async () => {
it("resolves alphanumeric issue identifiers before loading runs", async () => {
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-uuid-1",
companyId: "company-1",
@@ -141,10 +141,10 @@ describe.sequential("activity routes", () => {
]);
const app = await createApp();
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/issues/PAP-475/runs"));
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/issues/pc1a2-475/runs"));
expect(res.status).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475");
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-475");
expect(mockIssueService.getById).not.toHaveBeenCalled();
expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1");
expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]);
@@ -1,8 +1,16 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { models as openCodeFallbackModels } from "@paperclipai/adapter-opencode-local";
import type { ServerAdapterModule } from "../adapters/index.js";
vi.mock("acpx/runtime", () => ({
createAcpRuntime: vi.fn(),
createAgentRegistry: vi.fn(),
createRuntimeStore: vi.fn(),
isAcpRuntimeError: vi.fn(() => false),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
@@ -19,6 +27,10 @@ const mockSecretService = vi.hoisted(() => ({
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
}));
const mockEnvironmentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockListOpenCodeModels = vi.hoisted(() => vi.fn());
const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
@@ -55,6 +67,14 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn());
function registerModuleMocks() {
vi.doMock("@paperclipai/adapter-opencode-local/server", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-opencode-local/server")>("@paperclipai/adapter-opencode-local/server");
return {
...actual,
listOpenCodeModels: mockListOpenCodeModels,
};
});
vi.doMock("../services/index.js", () => ({
agentService: () => ({}),
agentInstructionsService: () => mockAgentInstructionsService,
@@ -74,6 +94,10 @@ function registerModuleMocks() {
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
}
const refreshableAdapterType = "refreshable_adapter_route_test";
@@ -147,6 +171,10 @@ describe("adapter model refresh route", () => {
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
mockEnvironmentService.getById.mockReset();
mockEnvironmentService.getById.mockResolvedValue(null);
mockListOpenCodeModels.mockReset();
mockListOpenCodeModels.mockResolvedValue([{ id: "dynamic-opencode-model", label: "dynamic-opencode-model" }]);
await unregisterTestAdapter(refreshableAdapterType);
});
@@ -182,4 +210,42 @@ describe("adapter model refresh route", () => {
expect(refreshModels).toHaveBeenCalledTimes(1);
expect(listModels).not.toHaveBeenCalled();
});
it("skips OpenCode model discovery for non-local environments", async () => {
mockEnvironmentService.getById.mockResolvedValue({
id: "env-1",
companyId: "company-1",
name: "Remote SSH",
driver: "ssh",
config: {},
});
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl).get("/api/companies/company-1/adapters/opencode_local/models?environmentId=env-1"),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toEqual(openCodeFallbackModels);
expect(mockListOpenCodeModels).not.toHaveBeenCalled();
});
it("keeps OpenCode model discovery enabled for local environments", async () => {
mockEnvironmentService.getById.mockResolvedValue({
id: "env-1",
companyId: "company-1",
name: "Local",
driver: "local",
config: {},
});
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl).get("/api/companies/company-1/adapters/opencode_local/models?environmentId=env-1"),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toEqual([{ id: "dynamic-opencode-model", label: "dynamic-opencode-model" }]);
expect(mockListOpenCodeModels).toHaveBeenCalledTimes(1);
});
});
+15 -1
View File
@@ -3,10 +3,17 @@ import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
import { listAdapterModels, refreshAdapterModels } from "../adapters/index.js";
import { listAdapterModels, listServerAdapters, refreshAdapterModels } from "../adapters/index.js";
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
vi.mock("acpx/runtime", () => ({
createAcpRuntime: vi.fn(),
createAgentRegistry: vi.fn(),
createRuntimeStore: vi.fn(),
isAcpRuntimeError: vi.fn(() => false),
}));
describe("adapter model listing", () => {
beforeEach(() => {
delete process.env.OPENAI_API_KEY;
@@ -23,6 +30,13 @@ describe("adapter model listing", () => {
expect(models).toEqual([]);
});
it("uses provider-prefixed ACPX fallback model labels", () => {
const adapter = listServerAdapters().find((candidate) => candidate.type === "acpx_local");
expect(adapter?.models?.some((model) => model.label.startsWith("Claude: "))).toBe(true);
expect(adapter?.models?.some((model) => model.label.startsWith("Codex: "))).toBe(true);
});
it("returns codex fallback models when no OpenAI key is available", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const models = await listAdapterModels("codex_local");
@@ -1,4 +1,5 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
import type { ServerAdapterModule } from "../adapters/index.js";
const hermesExecuteMock = vi.hoisted(() =>
@@ -232,6 +233,34 @@ describe("server adapter registry", () => {
await expect(listAdapterModelProfiles("pi_local")).resolves.toEqual([]);
});
it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => {
const expectedClaudeInstall = `if ! command -v 'claude' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@anthropic-ai/claude-code")}; fi`;
const expectedCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex")}; fi`;
const expectedGeminiInstall = `if ! command -v 'gemini' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@google/gemini-cli")}; fi`;
const expectedOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai")}; fi`;
expect(findActiveServerAdapter("claude_local")?.getRuntimeCommandSpec?.({})).toEqual({
command: "claude",
detectCommand: "claude",
installCommand: expectedClaudeInstall,
});
expect(findActiveServerAdapter("codex_local")?.getRuntimeCommandSpec?.({})).toEqual({
command: "codex",
detectCommand: "codex",
installCommand: expectedCodexInstall,
});
expect(findActiveServerAdapter("gemini_local")?.getRuntimeCommandSpec?.({})).toEqual({
command: "gemini",
detectCommand: "gemini",
installCommand: expectedGeminiInstall,
});
expect(findActiveServerAdapter("opencode_local")?.getRuntimeCommandSpec?.({})).toEqual({
command: "opencode",
detectCommand: "opencode",
installCommand: expectedOpenCodeInstall,
});
});
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
const builtIn = findServerAdapter("claude_local");
expect(builtIn).not.toBeNull();
+24 -2
View File
@@ -248,11 +248,33 @@ describe("adapter routes", () => {
]),
}),
expect.objectContaining({
key: "permissionMode",
default: "approve-all",
key: "fastMode",
default: false,
meta: { visibleWhen: { key: "agent", values: ["codex"] } },
}),
expect.objectContaining({
key: "warmHandleIdleMs",
default: 0,
}),
]),
);
const keys = res.body.fields.map((field: { key: string }) => field.key);
expect(keys).not.toContain("mode");
expect(keys).not.toContain("permissionMode");
expect(keys).not.toContain("instructionsFilePath");
expect(keys).not.toContain("promptTemplate");
expect(keys).not.toContain("bootstrapPromptTemplate");
});
it("GET /api/adapters includes ACPX model availability", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
expect(res.status, JSON.stringify(res.body)).toBe(200);
const acpxLocal = res.body.find((a: any) => a.type === "acpx_local");
expect(acpxLocal).toBeDefined();
expect(acpxLocal.modelsCount).toBeGreaterThan(0);
});
it("rejects signed-in users without org access", async () => {
@@ -53,6 +53,14 @@ vi.mock("../services/index.js", () => ({
workspaceOperationService: () => ({}),
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.mock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: mockFindServerAdapter,
listAdapterModels: vi.fn(),
@@ -75,6 +83,14 @@ function registerModuleMocks() {
workspaceOperationService: () => ({}),
}));
vi.doMock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.doMock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.doMock("../adapters/index.js", () => ({
findServerAdapter: mockFindServerAdapter,
listAdapterModels: vi.fn(),
@@ -12,6 +12,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
getActiveRunIssueSummaryForAgent: vi.fn(),
getRunLogAccess: vi.fn(),
readLog: vi.fn(),
wakeup: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
@@ -26,6 +27,8 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
listCompanyIds: vi.fn(),
}));
const routeAgentId = "11111111-1111-4111-8111-111111111111";
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
@@ -210,16 +213,24 @@ describe("agent live run routes", () => {
content: "chunk",
nextOffset: 5,
});
mockHeartbeatService.wakeup.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
status: "queued",
invocationSource: "on_demand",
triggerDetail: "manual",
});
});
it("returns a compact active run payload for issue polling", async () => {
const res = await requestApp(
await createApp(),
(baseUrl) => request(baseUrl).get("/api/issues/PAP-1295/active-run"),
(baseUrl) => request(baseUrl).get("/api/issues/pc1a2-1295/active-run"),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295");
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1295");
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
expect(res.body).toMatchObject({
id: "run-1",
@@ -268,7 +279,7 @@ describe("agent live run routes", () => {
const res = await requestApp(
await createApp(),
(baseUrl) => request(baseUrl).get("/api/issues/PAP-1295/active-run"),
(baseUrl) => request(baseUrl).get("/api/issues/PC1A2-1295/active-run"),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
@@ -524,4 +535,66 @@ describe("agent live run routes", () => {
expect(res.body).toHaveLength(4);
expect(db.select).toHaveBeenCalledTimes(2);
});
it("passes scoped wake fields through the legacy heartbeat invoke route", async () => {
const res = await requestApp(
await createApp(),
(baseUrl) => request(baseUrl)
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
.send({
reason: "issue_assigned",
payload: {
issueId: "issue-1",
taskId: "issue-1",
taskKey: "issue-1",
},
forceFreshSession: true,
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(202);
// The legacy /heartbeat/invoke endpoint forwards only the wake fields the
// caller actually supplied so empty-body callers (e.g. e2e suites) match
// the original fixed-arg `heartbeat.invoke()` shape exactly. When the
// caller supplies reason / payload / forceFreshSession those are
// forwarded; idempotencyKey is omitted unless explicitly set.
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "issue_assigned",
payload: {
issueId: "issue-1",
taskId: "issue-1",
taskKey: "issue-1",
},
requestedByActorType: "user",
requestedByActorId: "local-board",
contextSnapshot: {
triggeredBy: "board",
actorId: "local-board",
forceFreshSession: true,
},
});
});
it("calls heartbeat.wakeup with the legacy minimal shape when the body is empty", async () => {
const res = await requestApp(
await createApp(),
(baseUrl) => request(baseUrl)
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
.send({}),
);
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
source: "on_demand",
triggerDetail: "manual",
requestedByActorType: "user",
requestedByActorId: "local-board",
contextSnapshot: {
triggeredBy: "board",
actorId: "local-board",
},
});
});
});
@@ -1,6 +1,14 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
vi.mock("acpx/runtime", () => ({
createAcpRuntime: vi.fn(),
createAgentRegistry: vi.fn(),
createRuntimeStore: vi.fn(),
isAcpRuntimeError: vi.fn(() => false),
}));
const agentId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
@@ -908,6 +916,78 @@ describe.sequential("agent permission routes", () => {
);
});
it("seeds opencode agent creation with the static default model without live discovery", async () => {
mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue(
new Error("`opencode models` should not be called during creation"),
);
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/companies/${companyId}/agents`)
.send({
name: "OpenCode Builder",
role: "engineer",
adapterType: "opencode_local",
adapterConfig: {},
}));
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockEnsureOpenCodeModelConfiguredAndAvailable).not.toHaveBeenCalled();
expect(mockAgentService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
adapterType: "opencode_local",
adapterConfig: expect.objectContaining({
model: DEFAULT_OPENCODE_LOCAL_MODEL,
}),
}),
);
});
it("accepts manual opencode provider/model values without host-side discovery", async () => {
mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue(
new Error("`opencode models` should not be called during creation"),
);
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/companies/${companyId}/agents`)
.send({
name: "OpenCode Builder",
role: "engineer",
adapterType: "opencode_local",
adapterConfig: {
model: "anthropic/claude-sonnet-4-5",
},
}));
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockEnsureOpenCodeModelConfiguredAndAvailable).not.toHaveBeenCalled();
expect(mockAgentService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
adapterType: "opencode_local",
adapterConfig: expect.objectContaining({
model: "anthropic/claude-sonnet-4-5",
}),
}),
);
});
it("normalizes hire requests to disable timer heartbeats by default", async () => {
const app = await createApp({
type: "board",
@@ -0,0 +1,305 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ServerAdapterModule } from "../adapters/index.js";
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
getChainOfCommand: vi.fn(async () => []),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(async () => null),
listPrincipalGrants: vi.fn(async () => []),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
}));
const mockEnvironmentService = vi.hoisted(() => ({
getById: vi.fn(),
releaseLease: vi.fn(),
}));
const mockReleaseRunLease = vi.hoisted(() => vi.fn(async () => undefined));
const mockEnvironmentRuntime = vi.hoisted(() => ({
acquireRunLease: vi.fn(),
realizeWorkspace: vi.fn(),
getDriver: vi.fn(() => ({
releaseRunLease: mockReleaseRunLease,
})),
}));
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
const mockInstanceSettingsService = vi.hoisted(() => ({
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
}));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => ({}),
accessService: () => mockAccessService,
approvalService: () => ({}),
companySkillService: () => ({
listRuntimeSkillEntries: vi.fn(async () => []),
resolveRequestedSkillKeys: vi.fn(async () => []),
}),
budgetService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(),
cancelActiveForAgent: vi.fn(),
}),
ISSUE_LIST_DEFAULT_LIMIT: 50,
issueApprovalService: () => ({}),
issueService: () => ({}),
logActivity: vi.fn(),
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => ({}),
}));
vi.mock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.mock("../services/environment-runtime.js", () => ({
environmentRuntimeService: () => mockEnvironmentRuntime,
}));
vi.mock("../services/environment-execution-target.js", () => ({
resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget,
}));
vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
const testEnvironmentSpy = vi.fn();
const externalAdapter: ServerAdapterModule = {
type: "external_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: testEnvironmentSpy,
};
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", agentRoutes({} as any));
app.use(errorHandler);
return app;
}
async function unregisterTestAdapter(type: string) {
const { unregisterServerAdapter } = await import("../adapters/index.js");
unregisterServerAdapter(type);
}
describe("agent test-environment route", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
mockEnvironmentService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
name: "Sandbox QA",
driver: "sandbox",
config: { provider: "fake-plugin" },
});
mockEnvironmentRuntime.acquireRunLease.mockResolvedValue({
lease: {
id: "lease-1",
metadata: { remoteCwd: "/home/user/paperclip-workspace" },
},
leaseContext: {
executionWorkspaceId: null,
executionWorkspaceMode: null,
},
});
mockEnvironmentRuntime.realizeWorkspace.mockResolvedValue({
cwd: "/home/user/paperclip-workspace",
});
mockResolveEnvironmentExecutionTarget.mockResolvedValue(null);
testEnvironmentSpy.mockResolvedValue({
adapterType: "external_test",
status: "pass",
checks: [
{
code: "host_probe_ran",
level: "info",
message: "host probe should not run",
},
],
testedAt: new Date(0).toISOString(),
});
await unregisterTestAdapter("external_test");
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter(externalAdapter);
});
afterEach(async () => {
await unregisterTestAdapter("external_test");
});
it("does not fall back to a host probe when a requested environment cannot produce an execution target", async () => {
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/adapters/external_test/test-environment")
.send({
adapterConfig: {},
environmentId: "11111111-1111-4111-8111-111111111111",
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(testEnvironmentSpy).not.toHaveBeenCalled();
expect(res.body).toMatchObject({
adapterType: "external_test",
status: "warn",
checks: [
{
code: "environment_target_unsupported",
level: "warn",
message: 'Adapter "external_test" is not allowed in "Sandbox QA" environments.',
},
],
});
expect(mockReleaseRunLease).toHaveBeenCalledWith({
environment: expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
name: "Sandbox QA",
driver: "sandbox",
}),
lease: expect.objectContaining({
id: "lease-1",
}),
status: "failed",
});
});
it("returns a diagnostic result instead of probing the host when the requested environment is missing", async () => {
mockEnvironmentService.getById.mockResolvedValueOnce(null);
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/adapters/external_test/test-environment")
.send({
adapterConfig: {},
environmentId: "22222222-2222-4222-8222-222222222222",
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(testEnvironmentSpy).not.toHaveBeenCalled();
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
expect(res.body).toMatchObject({
adapterType: "external_test",
status: "warn",
checks: [
{
code: "environment_not_found",
level: "warn",
message: "Selected environment was not found. The test did not run.",
},
],
});
});
it("runs the adapter probe against the resolved sandbox target on the happy path and releases the lease on success", async () => {
mockResolveEnvironmentExecutionTarget.mockResolvedValueOnce({
kind: "remote",
transport: "sandbox",
remoteCwd: "/home/user/paperclip-workspace",
providerKey: "fake-plugin",
runner: { execute: vi.fn() },
});
testEnvironmentSpy.mockResolvedValueOnce({
adapterType: "external_test",
status: "pass",
checks: [
{
code: "external_test_hello_probe_passed",
level: "info",
message: "OK",
},
],
testedAt: new Date(0).toISOString(),
});
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/adapters/external_test/test-environment")
.send({
adapterConfig: {},
environmentId: "11111111-1111-4111-8111-111111111111",
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(testEnvironmentSpy).toHaveBeenCalledTimes(1);
expect(testEnvironmentSpy.mock.calls[0]?.[0]).toMatchObject({
executionTarget: expect.objectContaining({
kind: "remote",
transport: "sandbox",
}),
environmentName: "Sandbox QA",
});
expect(res.body).toMatchObject({ adapterType: "external_test", status: "pass" });
expect(mockReleaseRunLease).toHaveBeenCalledWith({
environment: expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
lease: expect.objectContaining({ id: "lease-1" }),
status: "released",
});
});
it("releases the lease as failed and returns a diagnostic when realizeWorkspace throws", async () => {
mockEnvironmentRuntime.realizeWorkspace.mockRejectedValueOnce(
new Error("workspace realization failed"),
);
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/adapters/external_test/test-environment")
.send({
adapterConfig: {},
environmentId: "11111111-1111-4111-8111-111111111111",
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(testEnvironmentSpy).not.toHaveBeenCalled();
expect(res.body).toMatchObject({
adapterType: "external_test",
status: "fail",
checks: [
expect.objectContaining({
code: "environment_workspace_realize_failed",
level: "error",
}),
],
});
expect(mockReleaseRunLease).toHaveBeenCalledWith({
environment: expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
lease: expect.objectContaining({ id: "lease-1" }),
status: "failed",
});
});
});
@@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { actorMiddleware } from "../middleware/auth.js";
function createSelectChain(rows: unknown[]) {
@@ -25,6 +25,13 @@ function createDb() {
}
describe("actorMiddleware authenticated session profile", () => {
const originalCloudTenantToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
afterEach(() => {
if (originalCloudTenantToken === undefined) delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
else process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = originalCloudTenantToken;
});
it("preserves the signed-in user name and email on the board actor", async () => {
const app = express();
app.use(
@@ -58,4 +65,72 @@ describe("actorMiddleware authenticated session profile", () => {
isInstanceAdmin: false,
});
});
it("trusts Cloud tenant identity headers and seeds board access", async () => {
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-token";
const inserts: Array<{ values: Record<string, unknown> }> = [];
const db = {
insert: vi.fn(() => {
const chain = {
values(values: Record<string, unknown>) {
inserts.push({ values });
return chain;
},
onConflictDoUpdate() {
return chain;
},
onConflictDoNothing() {
return chain;
},
returning() {
return Promise.resolve([{
companyId: inserts.at(-1)?.values.companyId,
membershipRole: inserts.at(-1)?.values.membershipRole,
status: inserts.at(-1)?.values.status,
}]);
},
};
return chain;
}),
select: vi.fn(),
} as any;
const app = express();
app.use(
actorMiddleware(db, {
deploymentMode: "authenticated",
resolveSession: async () => null,
}),
);
app.get("/actor", (req, res) => {
res.json(req.actor);
});
const res = await request(app)
.get("/actor")
.set("x-paperclip-cloud-tenant-token", "tenant-token")
.set("x-paperclip-cloud-user-id", "global-user-1")
.set("x-paperclip-cloud-user-email", "owner@example.com")
.set("x-paperclip-cloud-user-name", "Stack Owner")
.set("x-paperclip-cloud-stack-id", "stack-alpha")
.set("x-paperclip-cloud-paperclip-company-id", "paperclip-stack-alpha")
.set("x-paperclip-cloud-stack-role", "owner");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
type: "board",
userId: "global-user-1",
userName: "Stack Owner",
userEmail: "owner@example.com",
source: "cloud_tenant",
isInstanceAdmin: true,
memberships: [expect.objectContaining({ membershipRole: "owner", status: "active" })],
});
expect(res.body.companyIds[0]).toMatch(/^[0-9a-f-]{36}$/);
expect(inserts).toHaveLength(4);
expect(inserts[0]?.values).toMatchObject({
id: "global-user-1",
email: "owner@example.com",
emailVerified: true,
});
});
});
@@ -0,0 +1,820 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAwsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
import { SecretProviderClientError } from "../secrets/types.js";
describe("awsSecretsManagerProvider", () => {
const previousEnv = {
PAPERCLIP_SECRETS_AWS_REGION: process.env.PAPERCLIP_SECRETS_AWS_REGION,
AWS_REGION: process.env.AWS_REGION,
AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION,
PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID: process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID,
PAPERCLIP_SECRETS_AWS_KMS_KEY_ID: process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID,
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,
};
afterEach(() => {
vi.restoreAllMocks();
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("creates Paperclip-managed AWS secrets without persisting plaintext in provider material", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret(input) {
calls.push({ op: "createSecret", input });
return {
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
VersionId: "aws-version-1",
};
},
async putSecretValue(input) {
calls.push({ op: "putSecretValue", input });
return { ARN: String(input.SecretId), VersionId: "unused" };
},
async getSecretValue(input) {
calls.push({ op: "getSecretValue", input });
return { SecretString: "resolved-value", VersionId: "unused" };
},
async deleteSecret(input) {
calls.push({ op: "deleteSecret", input });
return {};
},
},
});
const prepared = await provider.createSecret({
value: "super-secret-value",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 1,
},
});
expect(calls).toEqual([
expect.objectContaining({
op: "createSecret",
input: expect.objectContaining({
Name: "paperclip/prod-use1/company-1/openai-api-key",
KmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
}),
}),
]);
expect(JSON.stringify(prepared)).not.toContain("super-secret-value");
expect(prepared.externalRef).toContain("paperclip/prod-use1/company-1/openai-api-key");
expect(prepared.providerVersionRef).toBe("aws-version-1");
});
it("creates AWS secrets from selected provider vault config without deployment env fallback", async () => {
delete process.env.PAPERCLIP_SECRETS_AWS_REGION;
delete process.env.AWS_REGION;
delete process.env.AWS_DEFAULT_REGION;
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
gateway: {
async createSecret(input) {
calls.push({ op: "createSecret", input });
return {
ARN: "arn:aws:secretsmanager:us-west-2:123456789012:secret:clip/prod-us-west/company-1/openai-api-key",
VersionId: "aws-version-1",
};
},
async putSecretValue(input) {
calls.push({ op: "putSecretValue", input });
return { ARN: String(input.SecretId), VersionId: "unused" };
},
async getSecretValue(input) {
calls.push({ op: "getSecretValue", input });
return { SecretString: "resolved-value", VersionId: "unused" };
},
async deleteSecret(input) {
calls.push({ op: "deleteSecret", input });
return {};
},
},
});
const providerConfig = {
id: "vault-1",
provider: "aws_secrets_manager" as const,
status: "ready",
config: {
region: "us-west-2",
namespace: "prod-us-west",
secretNamePrefix: "clip",
ownerTag: "platform",
environmentTag: "production",
},
};
const health = await provider.healthCheck({ providerConfig });
const prepared = await provider.createSecret({
value: "super-secret-value",
providerConfig,
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 1,
},
});
expect(health.status).toBe("ok");
expect(health.details).toMatchObject({
region: "us-west-2",
prefix: "clip",
deploymentId: "prod-us-west",
kmsKeyConfigured: false,
});
expect(calls).toEqual([
expect.objectContaining({
op: "createSecret",
input: expect.objectContaining({
Name: "clip/prod-us-west/company-1/openai-api-key",
SecretString: "super-secret-value",
Tags: expect.arrayContaining([
{ Key: "paperclip:provider-owner", Value: "platform" },
{ Key: "paperclip:environment", Value: "production" },
]),
}),
}),
]);
expect(calls[0]?.input).not.toHaveProperty("KmsKeyId");
expect(JSON.stringify(prepared)).not.toContain("super-secret-value");
expect(prepared.externalRef).toContain("clip/prod-us-west/company-1/openai-api-key");
});
it("signs AWS Secrets Manager JSON requests with default runtime credentials", async () => {
process.env.AWS_ACCESS_KEY_ID = "AKIA_TEST_ACCESS";
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
process.env.AWS_SESSION_TOKEN = "test-session-token";
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/company-1/openai-api-key",
VersionId: "aws-version-1",
}),
{ status: 200 },
),
);
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
});
await provider.createSecret({
value: "super-secret-value",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 1,
},
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
const headers = init?.headers as Record<string, string>;
expect(String(url)).toBe("https://secretsmanager.us-east-1.amazonaws.com/");
expect(headers["x-amz-target"]).toBe("secretsmanager.CreateSecret");
expect(headers["x-amz-security-token"]).toBe("test-session-token");
expect(headers.authorization).toContain("Credential=AKIA_TEST_ACCESS/");
expect(headers.authorization).toContain("/us-east-1/secretsmanager/aws4_request");
expect(headers.authorization).toContain("SignedHeaders=");
expect(headers.authorization).toContain("Signature=");
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
it("creates new AWS secret versions against a namespace-valid existing secret reference", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue(input) {
calls.push({ op: "putSecretValue", input });
return {
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
VersionId: "aws-version-2",
};
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret() {
throw new Error("not used");
},
},
});
const prepared = await provider.createVersion({
value: "rotated-secret-value",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
});
expect(calls).toEqual([
{
op: "putSecretValue",
input: {
SecretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
SecretString: "rotated-secret-value",
VersionStages: ["PAPERCLIP_PENDING"],
},
},
]);
expect(JSON.stringify(prepared)).not.toContain("rotated-secret-value");
expect(prepared.providerVersionRef).toBe("aws-version-2");
});
it("rejects out-of-namespace refs for managed AWS secret version writes", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue(input) {
calls.push({ op: "putSecretValue", input });
return { Name: String(input.SecretId), VersionId: "aws-version-2" };
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret() {
throw new Error("not used");
},
},
});
await expect(
provider.createVersion({
value: "rotated-secret-value",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
}),
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
expect(calls).toEqual([]);
});
it("stores linked external references as metadata-only provider material", async () => {
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
});
const prepared = await provider.linkExternalSecret({
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
providerVersionRef: "linked-version-7",
});
expect(prepared.externalRef).toBe(
"arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
);
expect(prepared.providerVersionRef).toBe("linked-version-7");
expect(prepared.valueSha256).toBeTruthy();
});
it("rejects linked external references under the Paperclip-managed namespace", async () => {
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
});
await expect(
provider.linkExternalSecret({
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
providerVersionRef: "linked-version-7",
}),
).rejects.toThrow(/Paperclip-managed namespace/i);
});
it("lists remote AWS secrets with metadata only and never resolves plaintext", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("GetSecretValue must not be used for remote import preview");
},
async deleteSecret() {
throw new Error("not used");
},
async listSecrets(input) {
calls.push({ op: "listSecrets", input });
return {
NextToken: "token-2",
SecretList: [
{
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
Name: "prod/openai",
Description: "OpenAI API key",
CreatedDate: new Date("2026-05-06T00:00:00.000Z"),
Tags: [{ Key: "team", Value: "platform" }],
},
],
};
},
},
});
const listed = await provider.listRemoteSecrets?.({
query: "openai",
nextToken: "token-1",
pageSize: 25,
});
expect(calls).toEqual([
{
op: "listSecrets",
input: {
MaxResults: 25,
NextToken: "token-1",
IncludePlannedDeletion: false,
Filters: [{ Key: "all", Values: ["openai"] }],
},
},
]);
expect(listed).toEqual({
nextToken: "token-2",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "prod/openai",
providerVersionRef: null,
metadata: expect.objectContaining({
createdDate: "2026-05-06T00:00:00.000Z",
hasDescription: true,
tagCount: 1,
}),
},
],
});
expect(JSON.stringify(listed)).not.toContain("SecretString");
expect(JSON.stringify(listed)).not.toContain("OpenAI API key");
expect(JSON.stringify(listed)).not.toContain("team");
});
it("redacts AWS provider exception text when remote listing fails", async () => {
const rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret() {
throw new Error("not used");
},
async listSecrets() {
throw new Error(rawProviderMessage);
},
},
});
let thrown: unknown;
try {
await provider.listRemoteSecrets?.({});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(SecretProviderClientError);
expect(thrown).toMatchObject({
code: "access_denied",
status: 403,
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage: rawProviderMessage,
});
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("123456789012");
});
it("resolves AWS secret values by provider version reference", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue(input) {
calls.push({ op: "getSecretValue", input });
return { SecretString: "resolved-secret-value", VersionId: "aws-version-2" };
},
async deleteSecret() {
throw new Error("not used");
},
},
});
const resolved = await provider.resolveVersion({
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
versionId: "aws-version-2",
source: "managed",
},
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
providerVersionRef: "aws-version-2",
context: {
companyId: "company-1",
secretId: "secret-1",
secretKey: "openai-api-key",
version: 2,
},
});
expect(resolved).toBe("resolved-secret-value");
expect(calls).toEqual([
{
op: "getSecretValue",
input: {
SecretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
VersionId: "aws-version-2",
VersionStage: undefined,
},
},
]);
});
it("rejects managed resolve attempts when stored refs drift outside the derived scope", async () => {
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("should not be called");
},
async deleteSecret() {
throw new Error("not used");
},
},
});
await expect(
provider.resolveVersion({
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
versionId: "aws-version-2",
source: "managed",
},
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
providerVersionRef: "aws-version-2",
context: {
companyId: "company-1",
secretId: "secret-1",
secretKey: "openai-api-key",
version: 2,
},
}),
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
});
it("warns when AWS provider configuration is incomplete and blocks managed writes", async () => {
delete process.env.PAPERCLIP_SECRETS_AWS_REGION;
delete process.env.AWS_REGION;
delete process.env.AWS_DEFAULT_REGION;
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
const provider = createAwsSecretsManagerProvider();
const health = await provider.healthCheck();
expect(health.status).toBe("warn");
expect(health.message).toContain("missing PAPERCLIP_SECRETS_AWS_REGION");
expect(health.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining("Missing required non-secret AWS provider config"),
expect.stringContaining("AWS bootstrap credentials must be available"),
expect.stringContaining("Do not store AWS root credentials"),
]),
);
expect(health.details).toMatchObject({
missingConfig: [
"PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION",
"PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID",
"PAPERCLIP_SECRETS_AWS_KMS_KEY_ID",
],
credentialSource: "AWS SDK default credential provider chain",
});
await expect(
provider.createSecret({
value: "super-secret-value",
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 1,
},
}),
).rejects.toThrow(/PAPERCLIP_SECRETS_AWS_REGION|AWS_REGION/i);
});
it("deletes only Paperclip-managed AWS secrets", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret(input) {
calls.push({ op: "deleteSecret", input });
return {};
},
},
});
await provider.deleteOrArchive({
mode: "delete",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
versionId: null,
source: "managed",
},
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
});
await expect(
provider.deleteOrArchive({
mode: "delete",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
versionId: null,
source: "managed",
},
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
}),
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
await provider.deleteOrArchive({
mode: "delete",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
versionId: "linked-version-7",
source: "external_reference",
},
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
});
expect(calls).toEqual([
{
op: "deleteSecret",
input: {
SecretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
RecoveryWindowInDays: 30,
},
},
]);
});
it("archives pending Paperclip-managed AWS versions without deleting the secret", async () => {
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
config: {
region: "us-east-1",
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
deploymentId: "prod-use1",
prefix: "paperclip",
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
environmentTag: "production",
providerOwnerTag: "paperclip",
deleteRecoveryWindowDays: 30,
},
gateway: {
async createSecret() {
throw new Error("not used");
},
async putSecretValue() {
throw new Error("not used");
},
async getSecretValue() {
throw new Error("not used");
},
async deleteSecret(input) {
calls.push({ op: "deleteSecret", input });
return {};
},
async updateSecretVersionStage(input) {
calls.push({ op: "updateSecretVersionStage", input });
return {};
},
},
});
await provider.deleteOrArchive({
mode: "archive",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
versionId: "aws-version-2",
source: "managed",
},
context: {
companyId: "company-1",
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 2,
},
});
expect(calls).toEqual([
{
op: "updateSecretVersionStage",
input: {
SecretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
VersionStage: "PAPERCLIP_PENDING",
RemoveFromVersionId: "aws-version-2",
},
},
]);
});
});
@@ -218,4 +218,64 @@ describe("claude_local environment diagnostics", () => {
).toBe(true);
expect(result.checks.some((check) => check.code === "claude_cwd_invalid")).toBe(false);
});
it("uses --allowedTools instead of --dangerously-skip-permissions for sandbox hello probes", async () => {
const executeCalls: Array<{ command: string; args?: string[] }> = [];
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: {
command: "claude",
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "cloudflare",
remoteCwd: "/workspace/paperclip",
runner: {
execute: async (input) => {
executeCalls.push({ command: input.command, args: input.args });
if (input.command === "claude") {
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello" }] } }),
JSON.stringify({
type: "result",
result: "hello",
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
}),
].join("\n"),
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
};
}
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
};
},
},
},
environmentName: "QA Cloudflare",
});
expect(result.checks.some((check) => check.code === "claude_hello_probe_passed")).toBe(true);
const probeCall = executeCalls.find((call) => call.command === "claude");
expect(probeCall?.args).not.toContain("--dangerously-skip-permissions");
expect(probeCall?.args).not.toContain("--permission-mode");
// Sandbox probes pass `--allowedTools` so any tool invocation triggered
// by the probe prompt cannot stall waiting for an interactive permission
// approval that no human is present to answer.
expect(probeCall?.args).toContain("--allowedTools");
});
});
@@ -21,6 +21,15 @@ describe("claude_local max-turn detection", () => {
).toBe(true);
});
it("checks every structured stop field for max-turn exhaustion", () => {
expect(
isClaudeMaxTurnsResult({
stop_reason: "end_turn",
stopReason: "max_turns_exhausted",
}),
).toBe(true);
});
it("returns false for non-max-turn results", () => {
expect(
isClaudeMaxTurnsResult({
@@ -29,6 +38,15 @@ describe("claude_local max-turn detection", () => {
}),
).toBe(false);
});
it("does not detect max-turn exhaustion from unstructured result text", () => {
expect(
isClaudeMaxTurnsResult({
subtype: "error",
result: "Tool output said: Maximum turns reached.",
}),
).toBe(false);
});
});
describe("claude_local ui stdout parser", () => {
@@ -19,6 +19,24 @@ process.exit(${exit});
await fs.chmod(commandPath, 0o755);
}
async function writeTextFailingClaudeCommand(
commandPath: string,
options: { stdout?: string; stderr?: string; exitCode?: number },
): Promise<void> {
const exit = options.exitCode ?? 1;
const script = `#!/usr/bin/env node
if (${JSON.stringify(options.stdout ?? "")}) {
process.stdout.write(${JSON.stringify(options.stdout ?? "")});
}
if (${JSON.stringify(options.stderr ?? "")}) {
process.stderr.write(${JSON.stringify(options.stderr ?? "")});
}
process.exit(${exit});
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
const fs = require("node:fs");
@@ -372,6 +390,119 @@ describe("claude execute", () => {
}
});
it("normalizes max-turn exhaustion into scheduler stop metadata", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turns-"));
const resultEvent = {
type: "result",
subtype: "error_max_turns",
session_id: "claude-session-1",
is_error: true,
result: "Maximum turns reached.",
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
};
const { workspace, commandPath, restore } = await setupExecuteEnv(root, {
commandWriter: (commandPath) => writeFailingClaudeCommand(commandPath, { resultEvent }),
});
try {
const result = await execute({
runId: "run-max-turns",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: commandPath,
cwd: workspace,
promptTemplate: "Do work.",
},
context: {},
authToken: "tok",
onLog: async () => {},
});
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("max_turns_exhausted");
expect(result.errorFamily).toBeNull();
expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" });
expect(result.clearSession).toBe(true);
} finally {
restore();
await fs.rm(root, { recursive: true, force: true });
}
});
it("does not normalize unstructured max-turn text into scheduler stop metadata", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turn-text-"));
const resultEvent = {
type: "result",
subtype: "error",
session_id: "claude-session-1",
is_error: true,
result: "Tool output said: Maximum turns reached.",
};
const { workspace, commandPath, restore } = await setupExecuteEnv(root, {
commandWriter: (commandPath) => writeFailingClaudeCommand(commandPath, { resultEvent }),
});
try {
const result = await execute({
runId: "run-max-turns-text",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: commandPath,
cwd: workspace,
promptTemplate: "Do work.",
},
context: {},
authToken: "tok",
onLog: async () => {},
});
expect(result.exitCode).toBe(1);
expect(result.errorCode).not.toBe("max_turns_exhausted");
expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted");
expect(result.clearSession).toBe(false);
} finally {
restore();
await fs.rm(root, { recursive: true, force: true });
}
});
it("does not normalize fallback stdout/stderr max-turn text into scheduler stop metadata", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turn-fallback-"));
const { workspace, commandPath, restore } = await setupExecuteEnv(root, {
commandWriter: (commandPath) =>
writeTextFailingClaudeCommand(commandPath, {
stdout: "attacker-controlled tool output: max turns exhausted\n",
stderr: "Maximum turns reached.\n",
}),
});
try {
const result = await execute({
runId: "run-max-turns-fallback-text",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: commandPath,
cwd: workspace,
promptTemplate: "Do work.",
},
context: {},
authToken: "tok",
onLog: async () => {},
});
expect(result.exitCode).toBe(1);
expect(result.errorCode).not.toBe("max_turns_exhausted");
expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted");
expect(result.clearSession).toBe(false);
} finally {
restore();
await fs.rm(root, { recursive: true, force: true });
}
});
it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-"));
const workspace = path.join(root, "workspace");
@@ -505,6 +636,11 @@ describe("claude execute", () => {
expect(result.exitCode).toBe(0);
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.argv).toContain("--allowedTools");
expect(capture.argv).toContain(
"Task AskUserQuestion Bash(*) CronCreate CronDelete CronList Edit EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Glob Grep Monitor NotebookEdit PushNotification Read RemoteTrigger ScheduleWakeup Skill TaskOutput TaskStop TodoWrite ToolSearch WebFetch WebSearch Write",
);
expect(capture.argv).not.toContain("--dangerously-skip-permissions");
expect(capture.claudeConfigDir).toBe(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "config"));
expect(capture.claudeConfigEntries).toContain("settings.json");
expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
@@ -517,7 +653,7 @@ describe("claude execute", () => {
else process.env.PATH = previousPath;
await fs.rm(root, { recursive: true, force: true });
}
});
}, 10_000);
it("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-"));
@@ -37,9 +37,11 @@ const projectSvc = {
const issueSvc = {
list: vi.fn(),
listComments: vi.fn(),
getById: vi.fn(),
getByIdentifier: vi.fn(),
create: vi.fn(),
addComment: vi.fn(),
};
const routineSvc = {
@@ -153,6 +155,14 @@ describe("company portability", () => {
config,
secretKeys: new Set<string>(),
}));
issueSvc.listComments.mockResolvedValue([]);
issueSvc.addComment.mockResolvedValue({
id: "comment-imported",
body: "Imported comment",
authorType: "system",
presentation: null,
metadata: null,
});
companySvc.getById.mockResolvedValue({
id: "company-1",
name: "Paperclip",
@@ -508,6 +518,70 @@ describe("company portability", () => {
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain("requireBoardApprovalForNewAgents: true");
});
it("exports legacy inline sensitive env values as declarations without values", async () => {
const portability = companyPortabilityService({} as any);
agentSvc.list.mockResolvedValue([
{
id: "agent-inline-secret",
name: "InlineSecretAgent",
status: "idle",
role: "engineer",
title: null,
icon: null,
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {
env: {
OPENAI_API_KEY: "sk-inline-secret-value",
NODE_ENV: {
type: "plain",
value: "development",
},
},
},
runtimeConfig: {},
budgetMonthlyCents: 0,
permissions: {
canCreateAgents: false,
},
metadata: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
const serialized = JSON.stringify(exported);
expect(serialized).not.toContain("sk-inline-secret-value");
expect(exported.manifest.envInputs).toContainEqual({
key: "OPENAI_API_KEY",
description: "Optional default for OPENAI_API_KEY on agent inlinesecretagent",
agentSlug: "inlinesecretagent",
projectSlug: null,
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
});
expect(exported.manifest.envInputs).toContainEqual({
key: "NODE_ENV",
description: "Optional default for NODE_ENV on agent inlinesecretagent",
agentSlug: "inlinesecretagent",
projectSlug: null,
kind: "plain",
requirement: "optional",
defaultValue: "development",
portability: "portable",
});
});
it("exports default sidebar order into the Paperclip extension and manifest", async () => {
const portability = companyPortabilityService({} as any);
@@ -2363,6 +2437,98 @@ describe("company portability", () => {
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
it("does not implicitly add local adapter permission bypass defaults on import", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "agent-created",
name: String(input.name),
adapterType: input.adapterType,
adapterConfig: input.adapterConfig,
}));
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
// Imports must preserve safe-by-default local adapter settings unless the package says otherwise.
const firstCreateInput = agentSvc.create.mock.calls[0]?.[1] as Record<string, any>;
expect(firstCreateInput?.adapterConfig).toBeTruthy();
expect(firstCreateInput.adapterConfig?.dangerouslySkipPermissions).toBeUndefined();
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
adapterConfig: {
extraArgs: [],
args: ["--legacy-arg"],
},
},
},
}, "user-1");
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({
adapterType: "codex_local",
adapterConfig: expect.objectContaining({
extraArgs: ["--skip-git-repo-check"],
args: ["--legacy-arg"],
}),
}));
const lastCreateInput = agentSvc.create.mock.calls.at(-1)?.[1] as Record<string, any>;
expect(lastCreateInput?.adapterConfig).toBeTruthy();
expect(lastCreateInput.adapterConfig?.dangerouslyBypassApprovalsAndSandbox).toBeUndefined();
});
it("preserves issue labelIds through export and import round-trip", async () => {
const portability = companyPortabilityService({} as any);
@@ -2429,6 +2595,204 @@ describe("company portability", () => {
);
});
it("preserves issue comment presentation fields through export and import", async () => {
const portability = companyPortabilityService({} as any);
const presentation = { kind: "system_notice", tone: "warning", detailsDefaultOpen: false };
const metadata = {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
};
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Needs disposition",
description: "System notice source",
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "high",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Paperclip needs a disposition before this issue can continue.",
presentation,
metadata,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("comments:");
expect(extension).toContain("system_notice");
expect(extension).toContain("successful_run_missing_state");
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Needs disposition" });
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(issueSvc.addComment).toHaveBeenCalledWith(
"issue-imported",
"Paperclip needs a disposition before this issue can continue.",
{ agentId: undefined, userId: undefined },
{
authorType: "system",
presentation,
metadata,
createdAt: "2026-05-04T12:00:00.000Z",
},
);
});
it("does not export raw comment author user ids", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Private board note",
description: null,
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
body: "Need private follow-up.",
presentation: null,
metadata: null,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain('authorType: "user"');
expect(extension).not.toContain("authorUserId: local-board");
});
it("downgrades user-authored imported comments to system when no importing user exists", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Private board note",
description: null,
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
body: "Need private follow-up.",
presentation: null,
metadata: null,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Private board note" });
const result = await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, null);
expect(issueSvc.addComment).toHaveBeenCalledWith(
"issue-imported",
"Need private follow-up.",
{ agentId: undefined, userId: undefined },
{
authorType: "system",
presentation: null,
metadata: null,
createdAt: "2026-05-04T12:00:00.000Z",
},
);
expect(result.warnings).toContain(
"Comment on task pap-1 was imported as a system comment because no importing user was available.",
);
});
it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => {
const portability = companyPortabilityService({} as any);
@@ -2599,7 +2963,7 @@ describe("company portability", () => {
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-imported",
expect.any(Object),
expect.anything(),
{ strictMode: false },
);
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
@@ -2665,7 +3029,10 @@ describe("company portability", () => {
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-1",
expect.any(Object),
expect.objectContaining({
model: "gpt-5.4",
extraArgs: ["--skip-git-repo-check"],
}),
{ strictMode: false },
);
expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({
@@ -0,0 +1,53 @@
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
import { createCompanySearchRateLimiter } from "../services/company-search-rate-limit.js";
import type { CompanySearchQuery, CompanySearchResponse } from "@paperclipai/shared";
function createSearchResponse(query: CompanySearchQuery): CompanySearchResponse {
return {
query: query.q,
normalizedQuery: query.q.trim().toLowerCase(),
scope: query.scope,
limit: query.limit,
offset: query.offset,
results: [],
countsByType: { issue: 0, agent: 0, project: 0 },
hasMore: false,
};
}
describe("company search route rate limiting", () => {
it("rejects repeated same-actor search calls before invoking search", async () => {
const search = vi.fn(async (_companyId: string, query: CompanySearchQuery) => createSearchResponse(query));
const app = express();
app.use((req, _res, next) => {
req.actor = {
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
};
next();
});
app.use("/api", issueRoutes({} as never, {} as never, {
searchService: { search },
searchRateLimiter: createCompanySearchRateLimiter({
maxRequests: 1,
windowMs: 60_000,
now: () => 1_000,
}),
}));
await request(app).get("/api/companies/company-1/search?q=wizard").expect(200);
const limited = await request(app).get("/api/companies/company-1/search?q=wizard").expect(429);
expect(search).toHaveBeenCalledTimes(1);
expect(limited.body).toMatchObject({
error: "Search rate limit exceeded",
retryAfterSeconds: 60,
});
expect(limited.headers["retry-after"]).toBe("60");
});
});
@@ -0,0 +1,454 @@
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
documents,
issueComments,
issueDocuments,
issues,
projects,
} from "@paperclipai/db";
import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
COMPANY_SEARCH_BRANCH_FETCH_LIMIT,
companySearchBranchFetchLimit,
companySearchService,
} from "../services/company-search.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres company search tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("company search query validation", () => {
it("clamps query length, limit, and offset without rejecting the request", () => {
const parsed = companySearchQuerySchema.parse({
q: "x".repeat(COMPANY_SEARCH_MAX_QUERY_LENGTH + 50),
limit: "500",
offset: "9000",
scope: "not-a-scope",
});
expect(parsed.q).toHaveLength(COMPANY_SEARCH_MAX_QUERY_LENGTH);
expect(parsed.limit).toBe(50);
expect(parsed.offset).toBe(200);
expect(parsed.scope).toBe("all");
});
it("includes offset in the internal per-branch fetch window", () => {
const lowOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "0" });
const highOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "9000" });
expect(companySearchBranchFetchLimit(lowOffset.limit, lowOffset.offset)).toBe(51);
expect(companySearchBranchFetchLimit(highOffset.limit, highOffset.offset)).toBe(COMPANY_SEARCH_BRANCH_FETCH_LIMIT);
});
});
describeEmbeddedPostgres("companySearchService", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof companySearchService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-search-");
db = createDb(tempDb.connectionString);
svc = companySearchService(db);
await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm"));
}, 20_000);
afterEach(async () => {
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function createCompany(name = "Paperclip") {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name,
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
return companyId;
}
async function createIssue(companyId: string, values: Partial<typeof issues.$inferInsert> = {}) {
const id = values.id ?? randomUUID();
await db.insert(issues).values({
id,
companyId,
title: values.title ?? "Search target",
description: values.description ?? null,
status: values.status ?? "todo",
priority: values.priority ?? "medium",
identifier: values.identifier ?? null,
hiddenAt: values.hiddenAt ?? null,
...values,
});
return id;
}
async function createAgent(companyId: string, values: Partial<typeof agents.$inferInsert> = {}) {
const id = values.id ?? randomUUID();
await db.insert(agents).values({
id,
companyId,
name: values.name ?? "Search agent",
role: values.role ?? "engineer",
title: values.title ?? null,
capabilities: values.capabilities ?? null,
...values,
});
return id;
}
async function createProject(companyId: string, values: Partial<typeof projects.$inferInsert> = {}) {
const id = values.id ?? randomUUID();
await db.insert(projects).values({
id,
companyId,
name: values.name ?? "Search project",
description: values.description ?? null,
...values,
});
return id;
}
it("ranks exact issue identifiers before weaker title matches", async () => {
const companyId = await createCompany();
const exactId = await createIssue(companyId, {
identifier: "TST-42",
title: "Backend endpoint",
});
await createIssue(companyId, {
identifier: "TST-43",
title: "TST-42 mentioned in title only",
});
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "TST-42" }));
expect(result.results[0]?.id).toBe(exactId);
expect(result.results[0]?.matchedFields).toContain("identifier");
});
it("matches multiple tokens across the same issue thread and returns comment snippets", async () => {
const companyId = await createCompany();
const issueId = await createIssue(companyId, {
identifier: "TST-7",
title: "Checkout semantics",
description: "Atomic ownership is enforced here.",
});
await db.insert(issueComments).values({
companyId,
issueId,
body: "The ranking snippet should explain why this thread matched.",
});
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "checkout snippet" }));
const match = result.results.find((item) => item.id === issueId);
expect(match).toBeTruthy();
expect(match?.matchedFields).toEqual(expect.arrayContaining(["title", "comment"]));
expect(match?.snippets.some((snippet) => /snippet/i.test(snippet.text))).toBe(true);
});
it("searches issue documents and returns document metadata for snippets", async () => {
const companyId = await createCompany();
const issueId = await createIssue(companyId, {
identifier: "TST-8",
title: "Adapter manager",
});
const documentId = randomUUID();
await db.insert(documents).values({
id: documentId,
companyId,
title: "Hermes Parser Plan",
latestBody: "The external adapter parser should be discovered from the plugin package.",
format: "markdown",
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "Hermes parser", scope: "documents" }));
expect(result.results).toHaveLength(1);
expect(result.results[0]?.id).toBe(issueId);
expect(result.results[0]?.matchedFields).toContain("document");
expect(result.results[0]?.href).toContain("#document-plan");
expect(result.results[0]?.snippet).toMatch(/parser/i);
});
it("excludes hidden issues and other companies' data", async () => {
const companyId = await createCompany("Visible Co");
const otherCompanyId = await createCompany("Other Co");
const visibleId = await createIssue(companyId, {
identifier: "VIS-1",
title: "Visible needle",
});
await createIssue(companyId, {
identifier: "HID-1",
title: "Hidden needle",
hiddenAt: new Date(),
});
await createIssue(otherCompanyId, {
identifier: "OTH-1",
title: "Other company needle",
});
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle" }));
expect(result.results.map((item) => item.id)).toEqual([visibleId]);
});
it("treats bare SQL wildcard characters as literals instead of match-all queries", async () => {
const companyId = await createCompany();
const issueId = await createIssue(companyId, {
identifier: "TST-20",
title: "Plain issue target",
description: "Plain issue description",
});
await db.insert(issueComments).values({
companyId,
issueId,
body: "Plain comment body",
});
const documentId = randomUUID();
await db.insert(documents).values({
id: documentId,
companyId,
title: "Plain document",
latestBody: "Plain document body",
format: "markdown",
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plain",
});
await createAgent(companyId, {
name: "Plain Agent",
role: "engineer",
capabilities: "Plain agent capabilities",
});
await createProject(companyId, {
name: "Plain Project",
description: "Plain project description",
});
for (const q of ["%", "_", "\\"]) {
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q }));
expect(result.results, `q=${q}`).toEqual([]);
}
});
it("matches percent characters literally across issue, comment, document, agent, and project results", async () => {
const companyId = await createCompany();
const issueMatchId = await createIssue(companyId, {
identifier: "TST-21",
title: "Release 100% checklist",
});
const issueDecoyId = await createIssue(companyId, {
identifier: "TST-22",
title: "Release 1000 checklist",
});
const commentMatchId = await createIssue(companyId, {
identifier: "TST-23",
title: "Comment literal holder",
});
const commentDecoyId = await createIssue(companyId, {
identifier: "TST-24",
title: "Comment decoy holder",
});
await db.insert(issueComments).values([
{
companyId,
issueId: commentMatchId,
body: "QA is 100% confident in this result.",
},
{
companyId,
issueId: commentDecoyId,
body: "QA is 1000 confident in this result.",
},
]);
const documentMatchIssueId = await createIssue(companyId, {
identifier: "TST-25",
title: "Document literal holder",
});
const documentDecoyIssueId = await createIssue(companyId, {
identifier: "TST-26",
title: "Document decoy holder",
});
const documentMatchId = randomUUID();
const documentDecoyId = randomUUID();
await db.insert(documents).values([
{
id: documentMatchId,
companyId,
title: "Literal rollout",
latestBody: "Ship 100% complete adapter support.",
format: "markdown",
},
{
id: documentDecoyId,
companyId,
title: "Decoy rollout",
latestBody: "Ship 1000 complete adapter support.",
format: "markdown",
},
]);
await db.insert(issueDocuments).values([
{
companyId,
issueId: documentMatchIssueId,
documentId: documentMatchId,
key: "literal",
},
{
companyId,
issueId: documentDecoyIssueId,
documentId: documentDecoyId,
key: "decoy",
},
]);
const agentMatchId = await createAgent(companyId, {
name: "100% Specialist",
role: "engineer",
});
const agentDecoyId = await createAgent(companyId, {
name: "1000 Specialist",
role: "engineer",
});
const projectMatchId = await createProject(companyId, {
name: "100% Launch Plan",
});
const projectDecoyId = await createProject(companyId, {
name: "1000 Launch Plan",
});
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "100%" }));
const ids = result.results.map((row) => row.id);
expect(ids).toEqual(expect.arrayContaining([
issueMatchId,
commentMatchId,
documentMatchIssueId,
agentMatchId,
projectMatchId,
]));
expect(ids).not.toEqual(expect.arrayContaining([
issueDecoyId,
commentDecoyId,
documentDecoyIssueId,
agentDecoyId,
projectDecoyId,
]));
});
it("applies offset after merging cross-type result ranking", async () => {
const companyId = await createCompany();
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
const agentIds = await Promise.all([
createAgent(companyId, { name: "Needle agent 1", updatedAt: new Date(base + 6_000) }),
createAgent(companyId, { name: "Needle agent 2", updatedAt: new Date(base + 5_000) }),
createAgent(companyId, { name: "Needle agent 3", updatedAt: new Date(base + 4_000) }),
]);
const projectIds = await Promise.all([
createProject(companyId, { name: "Needle project 1", updatedAt: new Date(base + 3_000) }),
createProject(companyId, { name: "Needle project 2", updatedAt: new Date(base + 2_000) }),
createProject(companyId, { name: "Needle project 3", updatedAt: new Date(base + 1_000) }),
]);
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", limit: "2", offset: "2" }));
expect(result.results.map((row) => row.id)).toEqual([agentIds[2], projectIds[0]]);
expect(result.countsByType).toEqual({ issue: 0, agent: 3, project: 3 });
expect(result.hasMore).toBe(true);
});
it("escapes underscore and backslash characters in issue phrase and token patterns", async () => {
const companyId = await createCompany();
const literalId = await createIssue(companyId, {
identifier: "TST-27",
title: "Literal foo_bar path c:\\tmp",
});
const decoyId = await createIssue(companyId, {
identifier: "TST-28",
title: "Decoy fooXbar path c:tmp",
});
for (const q of ["foo_bar", "c:\\tmp"]) {
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q, scope: "issues" }));
const ids = result.results.map((row) => row.id);
expect(ids, `q=${q}`).toContain(literalId);
expect(ids, `q=${q}`).not.toContain(decoyId);
}
});
it("uses pg_trgm for conservative fuzzy title matches", async () => {
const companyId = await createCompany();
const issueId = await createIssue(companyId, {
identifier: "TST-9",
title: "Onboarding wizard polish",
});
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "onbordng wizard" }));
expect(result.results[0]?.id).toBe(issueId);
expect(result.results[0]?.matchedFields).toContain("title");
});
it("matches transposition typos against multi-word titles", async () => {
const companyId = await createCompany();
const searchIssueId = await createIssue(companyId, {
identifier: "TST-10",
title: "Improve search performance",
});
const mobileIssueId = await createIssue(companyId, {
identifier: "TST-11",
title: "Polish mobile navigation",
});
const otherIssueId = await createIssue(companyId, {
identifier: "TST-12",
title: "Refactor billing reports",
});
const transpositionCases: Array<{ query: string; expectedId: string; rejected: string }> = [
{ query: "serach", expectedId: searchIssueId, rejected: otherIssueId },
{ query: "mibile", expectedId: mobileIssueId, rejected: otherIssueId },
{ query: "mobail", expectedId: mobileIssueId, rejected: otherIssueId },
];
for (const { query, expectedId, rejected } of transpositionCases) {
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: query }));
const ids = result.results.map((row) => row.id);
expect(ids, `query=${query}`).toContain(expectedId);
expect(ids, `query=${query} should not match unrelated issue`).not.toContain(rejected);
}
});
});
+188 -6
View File
@@ -3,7 +3,17 @@ import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { randomUUID } from "node:crypto";
import { createDb, companies, agents, costEvents, financeEvents, issues, projects } from "@paperclipai/db";
import {
createDb,
companies,
agents,
activityLog,
costEvents,
financeEvents,
heartbeatRuns,
issues,
projects,
} from "@paperclipai/db";
import { costService } from "../services/costs.ts";
import { financeService } from "../services/finance.ts";
import {
@@ -69,6 +79,8 @@ const mockCostService = vi.hoisted(() => ({
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
runCount: 0,
runtimeMs: 0,
}),
windowSpend: vi.fn().mockResolvedValue([]),
byProject: vi.fn().mockResolvedValue([]),
@@ -178,12 +190,12 @@ beforeEach(() => {
mockIssueService.getById.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
identifier: "PAP-1",
identifier: "PC1A2-1",
});
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
identifier: "PAP-1",
identifier: "PC1A2-1",
});
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
});
@@ -227,11 +239,13 @@ describe("cost routes", () => {
it("returns issue subtree cost summaries for issue refs", async () => {
const app = await createApp();
const res = await request(app).get("/api/issues/PAP-1/cost-summary");
const res = await request(app).get("/api/issues/pc1a2-1/cost-summary");
expect(res.status).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1");
expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1");
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1");
expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1", {
excludeRoot: false,
});
expect(res.body).toEqual({
issueId: "issue-1",
issueCount: 1,
@@ -240,6 +254,8 @@ describe("cost routes", () => {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
runCount: 0,
runtimeMs: 0,
});
});
@@ -393,6 +409,8 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
afterEach(async () => {
await db.delete(financeEvents);
await db.delete(costEvents);
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(projects);
await db.delete(agents);
@@ -612,9 +630,173 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
inputTokens: 60,
cachedInputTokens: 6,
outputTokens: 12,
runCount: 0,
runtimeMs: 0,
});
});
it("aggregates run wall-clock duration across the recursive issue tree", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const grandchildIssueId = randomUUID();
const siblingIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Run Agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "Root",
status: "in_progress",
priority: "medium",
issueNumber: 1,
identifier: "TST-1",
},
{
id: childIssueId,
companyId,
parentId: rootIssueId,
title: "Child",
status: "in_progress",
priority: "medium",
issueNumber: 2,
identifier: "TST-2",
},
{
id: grandchildIssueId,
companyId,
parentId: childIssueId,
title: "Grandchild",
status: "done",
priority: "medium",
issueNumber: 3,
identifier: "TST-3",
},
{
id: siblingIssueId,
companyId,
title: "Sibling",
status: "done",
priority: "medium",
issueNumber: 4,
identifier: "TST-4",
},
]);
const linkedViaContextRunId = randomUUID();
const linkedViaActivityRunId = randomUUID();
const grandchildRunId = randomUUID();
const siblingRunId = randomUUID();
const livePartialRunId = randomUUID();
await db.insert(heartbeatRuns).values([
// 60s run linked to root via contextSnapshot.issueId
{
id: linkedViaContextRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:00:00.000Z"),
finishedAt: new Date("2026-04-10T00:01:00.000Z"),
contextSnapshot: { issueId: rootIssueId },
},
// 120s run linked to child via activity_log
{
id: linkedViaActivityRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:05:00.000Z"),
finishedAt: new Date("2026-04-10T00:07:00.000Z"),
},
// 30s run linked to grandchild
{
id: grandchildRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:10:00.000Z"),
finishedAt: new Date("2026-04-10T00:10:30.000Z"),
contextSnapshot: { issueId: grandchildIssueId },
},
// sibling run NOT under root should be excluded
{
id: siblingRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:20:00.000Z"),
finishedAt: new Date("2026-04-10T00:21:00.000Z"),
contextSnapshot: { issueId: siblingIssueId },
},
// Still-running run on child (no finishedAt) should contribute (now - startedAt)
{
id: livePartialRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "running",
startedAt: new Date(Date.now() - 5_000),
contextSnapshot: { issueId: childIssueId },
},
]);
await db.insert(activityLog).values({
companyId,
runId: linkedViaActivityRunId,
actorType: "agent",
actorId: agentId,
agentId,
action: "issue.checked_out",
entityType: "issue",
entityId: childIssueId,
details: {},
});
const summary = await costs.issueTreeSummary(companyId, rootIssueId);
expect(summary.issueCount).toBe(3);
// 3 finished runs in tree (root, child via activity, grandchild) + 1 live run
expect(summary.runCount).toBe(4);
// 60s + 120s + 30s = 210s = 210_000ms from finished runs.
// Live run adds ~5_000ms; allow some slack so the assertion isn't flaky.
expect(summary.runtimeMs).toBeGreaterThanOrEqual(210_000 + 4_000);
expect(summary.runtimeMs).toBeLessThan(210_000 + 60_000);
// excludeRoot drops the root issue's own runs (the 60s contextSnapshot run)
// while keeping the child + grandchild runs and any live child run.
const descendantsOnly = await costs.issueTreeSummary(companyId, rootIssueId, {
excludeRoot: true,
});
expect(descendantsOnly.issueCount).toBe(2);
expect(descendantsOnly.runCount).toBe(3);
// 120s + 30s = 150s + ~5s live run
expect(descendantsOnly.runtimeMs).toBeGreaterThanOrEqual(150_000 + 4_000);
expect(descendantsOnly.runtimeMs).toBeLessThan(150_000 + 60_000);
});
it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => {
const companyId = randomUUID();
@@ -385,7 +385,7 @@ describe("cursor execute", () => {
else process.env.HOME = previousHome;
await fs.rm(root, { recursive: true, force: true });
}
});
}, 10_000);
it("keeps explicit command overrides for remote sandbox execution", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-sandbox-explicit-"));
@@ -54,14 +54,11 @@ describe("resolveEnvironmentExecutionTarget", () => {
remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD,
leaseId: "lease-1",
environmentId: "env-1",
paperclipTransport: "bridge",
timeoutMs: 30_000,
});
});
it("prefers an explicit Paperclip API URL from lease metadata for sandbox targets", async () => {
process.env.PAPERCLIP_API_URL = "https://paperclip.example.test";
process.env.PAPERCLIP_RUNTIME_API_URL = "http://paperclip.example.test:3200";
it("keeps sandbox targets on bridge mode even when lease metadata includes a Paperclip API URL", async () => {
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "sandbox",
config: {
@@ -93,8 +90,92 @@ describe("resolveEnvironmentExecutionTarget", () => {
expect(target).toMatchObject({
kind: "remote",
transport: "sandbox",
paperclipApiUrl: "https://paperclip.example.test",
paperclipTransport: "direct",
providerKey: "fake-plugin",
remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD,
});
expect(target).not.toHaveProperty("paperclipApiUrl");
expect(target).not.toHaveProperty("paperclipTransport");
});
it("passes through a provider-declared sandbox shell command from lease metadata", async () => {
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "sandbox",
config: {
provider: "fake-plugin",
reuseLease: false,
timeoutMs: 30_000,
},
});
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "claude_local",
environment: {
id: "env-1",
driver: "sandbox",
config: {
provider: "fake-plugin",
},
},
leaseId: "lease-1",
leaseMetadata: {
shellCommand: "bash",
},
lease: null,
environmentRuntime: null,
});
expect(target).toMatchObject({
kind: "remote",
transport: "sandbox",
shellCommand: "bash",
});
});
it("resolves SSH execution targets in bridge mode", async () => {
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "ssh",
config: {
host: "ssh.example.test",
port: 22,
username: "paperclip",
remoteWorkspacePath: "/srv/paperclip",
privateKey: "PRIVATE KEY",
knownHosts: "[ssh.example.test]:22 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
});
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "codex_local",
environment: {
id: "env-ssh-1",
driver: "ssh",
config: {},
},
leaseId: "lease-ssh-1",
leaseMetadata: {},
lease: null,
environmentRuntime: null,
});
expect(target).toMatchObject({
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip",
leaseId: "lease-ssh-1",
environmentId: "env-ssh-1",
spec: {
host: "ssh.example.test",
port: 22,
username: "paperclip",
remoteWorkspacePath: "/srv/paperclip",
remoteCwd: "/srv/paperclip",
},
});
expect(target).not.toHaveProperty("paperclipApiUrl");
});
});
@@ -161,9 +161,10 @@ describeLiveSsh("live SSH environment smoke", () => {
}
if (!resolvedConfig) {
throw new Error(
console.warn(
"Live SSH smoke test could not resolve SSH config from env vars or env-lab fixture. Set PAPERCLIP_ENV_LIVE_SSH_NO_AUTO_FIXTURE=true to mark this suite skipped intentionally.",
);
return;
}
const config = resolvedConfig;
@@ -171,7 +172,7 @@ describeLiveSsh("live SSH environment smoke", () => {
const quotedRemoteWorkspacePath = JSON.stringify(config.remoteWorkspacePath);
const result = await runSshCommand(
config,
`sh -lc "cd ${quotedRemoteWorkspacePath} && which git && which tar && pwd"`,
`cd ${quotedRemoteWorkspacePath} && which git && which tar && pwd`,
{ timeoutMs: 30000, maxBuffer: 256 * 1024 },
);
@@ -36,10 +36,13 @@ const mockProbeEnvironment = vi.hoisted(() => vi.fn());
const mockSecretService = vi.hoisted(() => ({
create: vi.fn(),
resolveSecretValue: vi.fn(),
syncSecretRefsForTarget: vi.fn(),
remove: vi.fn(),
}));
const mockValidatePluginEnvironmentDriverConfig = vi.hoisted(() => vi.fn());
const mockValidatePluginSandboxProviderConfig = vi.hoisted(() => vi.fn());
const mockListReadyPluginEnvironmentDrivers = vi.hoisted(() => vi.fn());
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
const mockExecutionWorkspaceService = vi.hoisted(() => ({}));
vi.mock("../services/index.js", () => ({
@@ -69,6 +72,7 @@ vi.mock("../services/execution-workspaces.js", () => ({
vi.mock("../services/plugin-environment-driver.js", () => ({
listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers,
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
validatePluginEnvironmentDriverConfig: mockValidatePluginEnvironmentDriverConfig,
validatePluginSandboxProviderConfig: mockValidatePluginSandboxProviderConfig,
}));
@@ -96,6 +100,7 @@ let currentActor: Record<string, unknown> = {
source: "local_implicit",
};
const routeOptions: Record<string, unknown> = {};
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
function createApp(actor: Record<string, unknown>, options: Record<string, unknown> = {}) {
currentActor = actor;
@@ -119,6 +124,11 @@ function createApp(actor: Record<string, unknown>, options: Record<string, unkno
describe("environment routes", () => {
afterAll(async () => {
if (originalSecretsProviderEnv === undefined) {
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
} else {
process.env.PAPERCLIP_SECRETS_PROVIDER = originalSecretsProviderEnv;
}
if (!server) return;
await new Promise<void>((resolve, reject) => {
server?.close((err) => {
@@ -145,9 +155,14 @@ describe("environment routes", () => {
mockProbeEnvironment.mockReset();
mockSecretService.create.mockReset();
mockSecretService.resolveSecretValue.mockReset();
mockSecretService.syncSecretRefsForTarget.mockReset();
mockSecretService.remove.mockReset();
mockSecretService.create.mockResolvedValue({
id: "11111111-1111-1111-1111-111111111111",
});
mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]);
mockSecretService.remove.mockResolvedValue(null);
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
mockValidatePluginEnvironmentDriverConfig.mockReset();
mockValidatePluginEnvironmentDriverConfig.mockImplementation(async ({ config }) => config);
mockValidatePluginSandboxProviderConfig.mockReset();
@@ -162,6 +177,29 @@ describe("environment routes", () => {
configSchema: { type: "object" },
},
}));
mockResolvePluginSandboxProviderDriverByKey.mockReset();
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(async ({ driverKey }) => (
driverKey === "secure-plugin"
? {
pluginId: "plugin-secure",
pluginKey: "acme.secure-sandbox-provider",
driver: {
driverKey: "secure-plugin",
kind: "sandbox_provider",
displayName: "Secure Sandbox",
configSchema: {
type: "object",
properties: {
template: { type: "string" },
apiKey: { type: "string", format: "secret-ref" },
timeoutMs: { type: "number" },
reuseLease: { type: "boolean" },
},
},
},
}
: null
));
mockListReadyPluginEnvironmentDrivers.mockReset();
mockListReadyPluginEnvironmentDrivers.mockResolvedValue([]);
});
@@ -555,6 +593,59 @@ describe("environment routes", () => {
);
});
it("uses the configured provider for SSH private key secret materialization", async () => {
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
const environment = {
...createEnvironment(),
id: "env-ssh",
name: "SSH Fixture",
driver: "ssh" as const,
config: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
privateKeySecretRef: {
type: "secret_ref",
secretId: "11111111-1111-1111-1111-111111111111",
version: "latest",
},
knownHosts: null,
strictHostKeyChecking: true,
},
};
mockEnvironmentService.create.mockResolvedValue(environment);
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.post("/api/companies/company-1/environments")
.send({
name: "SSH Fixture",
driver: "ssh",
config: {
host: "ssh.example.test",
username: "ssh-user",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: "super-secret-key",
},
});
expect(res.status).toBe(201);
expect(mockSecretService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
provider: "aws_secrets_manager",
value: "super-secret-key",
}),
expect.any(Object),
);
});
it("rejects persisted fake sandbox environments", async () => {
const app = createApp({
type: "board",
@@ -732,6 +823,78 @@ describe("environment routes", () => {
);
});
it("uses the configured provider for schema-driven sandbox secret fields", async () => {
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
const environment = {
...createEnvironment(),
id: "env-sandbox-secure-plugin",
name: "Secure Sandbox",
driver: "sandbox" as const,
config: {
provider: "secure-plugin",
template: "base",
apiKey: "11111111-1111-1111-1111-111111111111",
timeoutMs: 450000,
reuseLease: true,
},
};
mockEnvironmentService.create.mockResolvedValue(environment);
mockValidatePluginSandboxProviderConfig.mockResolvedValue({
normalizedConfig: {
template: "base",
apiKey: "test-provider-key",
timeoutMs: 450000,
reuseLease: true,
},
pluginId: "plugin-secure",
pluginKey: "acme.secure-sandbox-provider",
driver: {
driverKey: "secure-plugin",
kind: "sandbox_provider",
displayName: "Secure Sandbox",
configSchema: {
type: "object",
properties: {
template: { type: "string" },
apiKey: { type: "string", format: "secret-ref" },
timeoutMs: { type: "number" },
reuseLease: { type: "boolean" },
},
},
},
});
const pluginWorkerManager = {};
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
}, { pluginWorkerManager });
const res = await request(app)
.post("/api/companies/company-1/environments")
.send({
name: "Secure Sandbox",
driver: "sandbox",
config: {
provider: "secure-plugin",
template: "base",
apiKey: "test-provider-key",
timeoutMs: "450000",
reuseLease: true,
},
});
expect(res.status).toBe(201);
expect(mockSecretService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
provider: "aws_secrets_manager",
value: "test-provider-key",
}),
expect.any(Object),
);
});
it("validates plugin environment config through the plugin driver host", async () => {
const environment = {
...createEnvironment(),
@@ -420,6 +420,85 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
}));
});
it("runs project-level provision commands for ssh environments", async () => {
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
adapterType: "gemini_local",
companyId: "company-1",
environmentId: "env-1",
executionWorkspaceId: null,
issueId: null,
heartbeatRunId: "run-1",
requestedMode: null,
source: {
kind: "project_primary",
localPath: "/workspace/project",
projectId: null,
projectWorkspaceId: null,
repoUrl: null,
repoRef: null,
strategy: "project_primary",
branchName: null,
worktreePath: null,
},
runtimeOverlay: {
provisionCommand: "npm install -g @google/gemini-cli",
},
});
mockResolveEnvironmentExecutionTarget.mockResolvedValue({
kind: "remote",
transport: "ssh",
remoteCwd: "/remote/workspace",
environmentId: "env-1",
leaseId: "lease-1",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/remote/workspace",
remoteWorkspacePath: "/remote/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
});
const runtime = makeMockRuntime({
realizeWorkspace: vi.fn().mockResolvedValue({
cwd: "/remote/workspace",
metadata: {
workspaceRealization: {
version: 1,
transport: "ssh",
remote: { path: "/remote/workspace" },
},
},
}),
});
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
await orchestrator.realizeForRun(makeRealizeInput({
environment: makeEnvironment("ssh"),
lease: makeLease({
provider: "ssh",
metadata: {
driver: "ssh",
remoteCwd: "/remote/workspace",
remoteWorkspacePath: "/remote/workspace",
host: "ssh.example.test",
port: 22,
username: "ssh-user",
},
}),
}));
expect(runtime.execute).toHaveBeenCalledWith(expect.objectContaining({
command: "bash",
args: ["-lc", "npm install -g @google/gemini-cli"],
}));
expect(mockResolveEnvironmentExecutionTarget).toHaveBeenCalledOnce();
});
it("surfaces remote provision command failures before resolving the adapter target", async () => {
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { createServer, type Server } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -55,7 +54,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
const fixtureRoots: string[] = [];
const servers: Server[] = [];
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("environment-runtime-contract");
@@ -64,9 +62,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
});
afterEach(async () => {
for (const server of servers.splice(0)) {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
while (fixtureRoots.length > 0) {
const root = fixtureRoots.pop();
if (!root) continue;
@@ -123,6 +118,13 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
provider: "local_encrypted",
value: config.privateKey,
});
await secretService(db).createBinding({
companyId,
secretId: secret.id,
targetType: "environment",
targetId: environmentId,
configPath: "privateKeySecretRef",
});
config = {
...config,
privateKey: null,
@@ -172,27 +174,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
};
}
async function startHealthServer() {
const server = createServer((req, res) => {
if (req.url === "/api/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
servers.push(server);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected health server to listen on a TCP port.");
}
return `http://127.0.0.1:${address.port}`;
}
async function runContract(testCase: RuntimeContractCase) {
const cleanup = await testCase.setup?.();
try {
@@ -288,9 +269,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
fixtureRoots.push(fixtureRoot);
const fixture = await startSshEnvLabFixture({ statePath: path.join(fixtureRoot, "state.json") });
const sshConfig = await buildSshEnvLabFixtureConfig(fixture);
const runtimeApiUrl = await startHealthServer();
const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]);
await runContract({
name: "ssh",
@@ -304,16 +282,8 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
username: sshConfig.username,
remoteWorkspacePath: sshConfig.remoteWorkspacePath,
remoteCwd: sshConfig.remoteWorkspacePath,
paperclipApiUrl: runtimeApiUrl,
});
},
setup: async () => async () => {
if (previousCandidates === undefined) {
delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
} else {
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates;
}
},
});
});
});
+120 -33
View File
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -178,6 +177,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
provider: "local_encrypted",
value: config.privateKey,
});
await secretService(db).createBinding({
companyId,
secretId: secret.id,
targetType: "environment",
targetId: environmentId,
configPath: "privateKeySecretRef",
});
config = {
...config,
privateKey: null,
@@ -329,26 +335,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
const statePath = path.join(fixtureRoot, "state.json");
const fixture = await startSshEnvLabFixture({ statePath });
const sshConfig = await buildSshEnvLabFixtureConfig(fixture);
const healthServer = createServer((req, res) => {
if (req.url === "/api/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve, reject) => {
healthServer.once("error", reject);
healthServer.listen(0, "127.0.0.1", () => resolve());
});
const address = healthServer.address();
if (!address || typeof address === "string") {
await new Promise<void>((resolve) => healthServer.close(() => resolve()));
throw new Error("Expected the test health server to listen on a TCP port.");
}
const runtimeApiUrl = `http://127.0.0.1:${address.port}`;
const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]);
const { companyId, environment, runId } = await seedEnvironment({
driver: "ssh",
name: "Fixture SSH",
@@ -372,7 +358,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
username: sshConfig.username,
remoteWorkspacePath: sshConfig.remoteWorkspacePath,
remoteCwd: sshConfig.remoteWorkspacePath,
paperclipApiUrl: runtimeApiUrl,
});
const released = await runtime.releaseRunLeases(runId);
@@ -381,12 +366,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(released[0]?.environment.driver).toBe("ssh");
expect(released[0]?.lease.status).toBe("released");
} finally {
if (previousCandidates === undefined) {
delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
} else {
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates;
}
await new Promise<void>((resolve) => healthServer.close(() => resolve()));
}
});
@@ -552,7 +531,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(released).toHaveLength(1);
expect(released[0]?.lease.status).toBe("released");
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.anything(), 31000);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.anything());
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.anything(), 31234);
});
it("uses resolved secret-ref config for plugin-backed sandbox execute and release", async () => {
@@ -576,6 +555,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
driver: "sandbox",
config: providerConfig,
};
await secretService(db).createBinding({
companyId,
secretId: apiSecret.id,
targetType: "environment",
targetId: environment.id,
configPath: "apiKey",
});
await environmentService(db).update(environment.id, {
driver: "sandbox",
name: environment.name,
@@ -696,7 +682,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
config: expect.objectContaining({
apiKey: "resolved-provider-key",
}),
}));
}), 31234);
});
it("waits briefly for a ready sandbox provider plugin worker to come online", async () => {
@@ -788,7 +774,104 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(acquired.lease.providerLeaseId).toBe("sandbox-1");
expect(workerManager.isRunning).toHaveBeenCalledTimes(3);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.anything());
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.anything(), 31234);
});
it("extends plugin-backed sandbox lease RPC timeouts from provider config", async () => {
const pluginId = randomUUID();
const { companyId, environment: baseEnvironment, runId } = await seedEnvironment();
const providerConfig = {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 1_234,
bridgeRequestTimeoutMs: 40_000,
reuseLease: false,
};
const environment = {
...baseEnvironment,
name: "Long Lease Plugin Sandbox",
driver: "sandbox",
config: providerConfig,
};
await environmentService(db).update(environment.id, {
driver: "sandbox",
name: environment.name,
config: providerConfig,
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.long-lease-sandbox-provider",
packageName: "@acme/long-lease-sandbox-provider",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.long-lease-sandbox-provider",
apiVersion: 1,
version: "1.0.0",
displayName: "Long Lease Sandbox Provider",
description: "Test plugin worker acquire timeout",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [
{
driverKey: "fake-plugin",
kind: "sandbox_provider",
displayName: "Fake Plugin",
configSchema: { type: "object" },
},
],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
const workerManager = {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return {
providerLeaseId: "sandbox-1",
metadata: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 1_234,
bridgeRequestTimeoutMs: 40_000,
reuseLease: false,
},
};
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const acquired = await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(acquired.lease.providerLeaseId).toBe("sandbox-1");
expect(workerManager.call).toHaveBeenCalledWith(
pluginId,
"environmentAcquireLease",
expect.objectContaining({
driverKey: "fake-plugin",
config: {
image: "fake:test",
timeoutMs: 1_234,
bridgeRequestTimeoutMs: 40_000,
reuseLease: false,
},
}),
70_000,
);
});
it("falls back to acquire when plugin-backed sandbox lease resume throws", async () => {
@@ -898,7 +981,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentResumeLease", expect.objectContaining({
driverKey: "fake-plugin",
providerLeaseId: "stale-plugin-lease",
}));
}), 31234);
expect(workerManager.call).toHaveBeenNthCalledWith(2, pluginId, "environmentAcquireLease", expect.objectContaining({
driverKey: "fake-plugin",
config: {
@@ -907,7 +990,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
reuseLease: true,
},
runId,
}));
}), 31234);
});
it("releases a sandbox run lease from metadata after the environment config changes", async () => {
@@ -1022,6 +1105,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
driverKey: "fake-plugin",
companyId,
environmentId: environment.id,
issueId: null,
config: { template: "base" },
runId,
workspaceMode: undefined,
@@ -1057,6 +1141,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
driverKey: "fake-plugin",
companyId,
environmentId: environment.id,
issueId: null,
config: {},
providerLeaseId: "plugin-lease-1",
leaseMetadata: expect.objectContaining({
@@ -1215,6 +1300,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
driverKey: "fake-plugin",
companyId,
environmentId: environment.id,
issueId: null,
config: { template: "base" },
providerLeaseId: "plugin-lease-full",
leaseMetadata: expect.objectContaining({
@@ -1245,6 +1331,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
driverKey: "fake-plugin",
companyId,
environmentId: environment.id,
issueId: null,
config: { template: "base" },
providerLeaseId: "plugin-lease-full",
leaseMetadata: expect.objectContaining({
@@ -0,0 +1,65 @@
const readline = require("node:readline");
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
const rl = readline.createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
rl.on("line", (line) => {
if (!line.trim()) return;
const message = JSON.parse(line);
const method = message && typeof message.method === "string" ? message.method : null;
if (method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
ok: true,
supportedMethods: ["environmentExecute"],
},
});
return;
}
if (method === "environmentExecute") {
const delayMs = Number(message.params?.delayMs ?? 0);
setTimeout(() => {
send({
jsonrpc: "2.0",
id: message.id,
result: {
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok\n",
stderr: "",
},
});
}, delayMs);
return;
}
if (method === "shutdown") {
send({
jsonrpc: "2.0",
id: message.id,
result: {},
});
setImmediate(() => process.exit(0));
return;
}
send({
jsonrpc: "2.0",
id: message.id,
error: {
code: -32601,
message: `Unhandled method: ${method}`,
},
});
});
@@ -0,0 +1,59 @@
const readline = require("node:readline");
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
const rl = readline.createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
rl.on("line", (line) => {
if (!line.trim()) return;
const message = JSON.parse(line);
const method = message && typeof message.method === "string" ? message.method : null;
if (method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
ok: true,
supportedMethods: ["environmentExecute"],
},
});
return;
}
if (method === "environmentExecute") {
send({
jsonrpc: "2.0",
id: message.id,
error: {
code: -32002,
message: "[unknown] terminated",
},
});
return;
}
if (method === "shutdown") {
send({
jsonrpc: "2.0",
id: message.id,
result: {},
});
setImmediate(() => process.exit(0));
return;
}
send({
jsonrpc: "2.0",
id: message.id,
error: {
code: -32601,
message: `Unhandled method: ${method}`,
},
});
});
@@ -131,4 +131,57 @@ describe("gemini_local environment diagnostics", () => {
expect(result.checks.some((check) => check.code === "gemini_hello_probe_quota_exhausted")).toBe(true);
await fs.rm(root, { recursive: true, force: true });
});
it("trusts remote sandbox workspaces during the hello probe", async () => {
let probeEnv: Record<string, string> | undefined;
const result = await testEnvironment({
companyId: "company-1",
adapterType: "gemini_local",
config: {
command: "gemini",
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "cloudflare",
remoteCwd: "/workspace/paperclip",
runner: {
execute: async (input) => {
if (input.command === "gemini") {
probeEnv = input.env;
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
}),
JSON.stringify({ type: "result", subtype: "success", result: "hello" }),
].join("\n"),
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
};
}
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
};
},
},
},
environmentName: "QA Cloudflare",
});
expect(result.checks.some((check) => check.code === "gemini_hello_probe_passed")).toBe(true);
expect(probeEnv?.GEMINI_CLI_TRUST_WORKSPACE).toBe("true");
});
});
@@ -1,29 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { isGeminiUnknownSessionError, parseGeminiJsonl } from "@paperclipai/adapter-gemini-local/server";
import {
isGeminiTurnLimitResult,
isGeminiUnknownSessionError,
parseGeminiJsonl,
} from "@paperclipai/adapter-gemini-local/server";
import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
describe("gemini_local parser", () => {
it("extracts session, summary, usage, cost, and terminal error message", () => {
it("extracts session, summary, usage, cost, and terminal error message from v0.38 stream-json output", () => {
const stdout = [
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
JSON.stringify({
type: "assistant",
message: {
content: [{ type: "output_text", text: "hello" }],
},
type: "message",
role: "assistant",
content: "hello",
}),
JSON.stringify({
type: "result",
subtype: "success",
status: "success",
session_id: "gemini-session-1",
usage: {
promptTokenCount: 12,
cachedContentTokenCount: 3,
candidatesTokenCount: 7,
stats: {
input_tokens: 12,
cached_input_tokens: 3,
output_tokens: 7,
},
total_cost_usd: 0.00123,
result: "done",
}),
JSON.stringify({ type: "error", message: "model access denied" }),
].join("\n");
@@ -79,45 +81,56 @@ describe("gemini_local stale session detection", () => {
});
});
describe("gemini_local turn-limit detection", () => {
it("detects structured turn-limit signals and exit code 53", () => {
expect(isGeminiTurnLimitResult({ status: "turn_limit" })).toBe(true);
expect(isGeminiTurnLimitResult({ stopReason: "max_turns_exhausted" })).toBe(true);
expect(isGeminiTurnLimitResult(null, 53)).toBe(true);
});
it("checks every structured stop field for turn-limit exhaustion", () => {
expect(
isGeminiTurnLimitResult({
status: "success",
stopReason: "turn_limit_exhausted",
}),
).toBe(true);
});
it("does not detect turn-limit exhaustion from unstructured error text", () => {
expect(isGeminiTurnLimitResult({ error: "max_turns reached" })).toBe(false);
});
});
describe("gemini_local ui stdout parser", () => {
it("parses assistant, thinking, and result events", () => {
it("parses v0.38 assistant message and result events", () => {
const ts = "2026-03-08T00:00:00.000Z";
expect(
parseGeminiStdoutLine(
JSON.stringify({
type: "assistant",
message: {
content: [
{ type: "output_text", text: "I checked the repo." },
{ type: "thinking", text: "Reviewing adapter registry" },
{ type: "tool_call", name: "shell", input: { command: "ls -1" } },
{ type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" },
],
},
type: "message",
role: "assistant",
content: "I checked the repo.",
}),
ts,
),
).toEqual([
{ kind: "assistant", ts, text: "I checked the repo." },
{ kind: "thinking", ts, text: "Reviewing adapter registry" },
{ kind: "tool_call", ts, name: "shell", input: { command: "ls -1" } },
{ kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false },
]);
expect(
parseGeminiStdoutLine(
JSON.stringify({
type: "result",
subtype: "success",
result: "Done",
usage: {
promptTokenCount: 10,
candidatesTokenCount: 5,
cachedContentTokenCount: 2,
status: "success",
text: "Done",
stats: {
input_tokens: 10,
output_tokens: 5,
cached_input_tokens: 2,
},
total_cost_usd: 0.00042,
is_error: false,
}),
ts,
),
@@ -143,7 +156,7 @@ function stripAnsi(value: string): string {
}
describe("gemini_local cli formatter", () => {
it("prints init, assistant, result, and error events", () => {
it("prints init, v0.38 assistant, result, and error events", () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
let joined = "";
@@ -154,19 +167,20 @@ describe("gemini_local cli formatter", () => {
);
printGeminiStreamEvent(
JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
type: "message",
role: "assistant",
content: "hello",
}),
false,
);
printGeminiStreamEvent(
JSON.stringify({
type: "result",
subtype: "success",
usage: {
promptTokenCount: 10,
candidatesTokenCount: 5,
cachedContentTokenCount: 2,
status: "success",
stats: {
input_tokens: 10,
output_tokens: 5,
cached_input_tokens: 2,
},
total_cost_usd: 0.00042,
}),
@@ -39,6 +39,35 @@ console.log(JSON.stringify({
await fs.chmod(commandPath, 0o755);
}
async function writeFailingGeminiCommand(
commandPath: string,
options: {
stdoutLines?: Array<Record<string, unknown>>;
stdout?: string;
stderr?: string;
exitCode?: number;
},
): Promise<void> {
const stdoutLines = options.stdoutLines ?? [];
const stdout = options.stdout ?? "";
const stderr = options.stderr ?? "";
const exit = options.exitCode ?? 1;
const script = `#!/usr/bin/env node
for (const line of ${JSON.stringify(stdoutLines.map((line) => JSON.stringify(line)))}) {
console.log(line);
}
if (${JSON.stringify(stdout)}) {
process.stdout.write(${JSON.stringify(stdout)});
}
if (${JSON.stringify(stderr)}) {
console.error(${JSON.stringify(stderr)});
}
process.exit(${exit});
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
type CapturePayload = {
argv: string[];
paperclipEnvKeys: string[];
@@ -169,6 +198,144 @@ describe("gemini execute", () => {
}
});
it("normalizes turn-limit exhaustion into scheduler stop metadata", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-max-turns-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
await fs.mkdir(workspace, { recursive: true });
await writeFailingGeminiCommand(commandPath, {
stdoutLines: [
{
type: "result",
subtype: "error",
session_id: "gemini-session-1",
status: "turn_limit",
error: "Turn limit reached.",
},
],
});
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-turn-limit",
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: commandPath,
cwd: workspace,
},
context: {},
authToken: "t",
onLog: async () => {},
});
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("max_turns_exhausted");
expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" });
expect(result.clearSession).toBe(true);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("normalizes Gemini exit code 53 as max-turn exhaustion", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-exit-53-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
await fs.mkdir(workspace, { recursive: true });
await writeFailingGeminiCommand(commandPath, {
stderr: "Gemini stopped because the max turns limit was reached.",
exitCode: 53,
});
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-exit-53",
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: commandPath,
cwd: workspace,
},
context: {},
authToken: "t",
onLog: async () => {},
});
expect(result.exitCode).toBe(53);
expect(result.errorCode).toBe("max_turns_exhausted");
expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" });
expect(result.clearSession).toBe(true);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("does not normalize unstructured turn-limit text into scheduler stop metadata", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-max-turn-text-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
await fs.mkdir(workspace, { recursive: true });
await writeFailingGeminiCommand(commandPath, {
stdoutLines: [
{
type: "result",
subtype: "error",
session_id: "gemini-session-1",
error: "Tool output said: maximum turns reached.",
},
],
stdout: "attacker-controlled transcript mentions turn limit reached\n",
stderr: "Gemini stopped because the max turns limit was reached.",
});
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-turn-limit-text",
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: commandPath,
cwd: workspace,
},
context: {},
authToken: "t",
onLog: async () => {},
});
expect(result.exitCode).toBe(1);
expect(result.errorCode).not.toBe("max_turns_exhausted");
expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted");
expect(result.clearSession).toBe(false);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-resume-wake-"));
const workspace = path.join(root, "workspace");
@@ -208,6 +208,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
expect(evaluations[0]).toMatchObject({
priority: "medium",
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
originId: runId,
originFingerprint: `stale_active_run:${companyId}:${runId}`,
});
@@ -13,6 +13,7 @@ import {
issues,
} from "@paperclipai/db";
import { heartbeatService } from "../services/heartbeat.ts";
import { SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY } from "../services/recovery/index.ts";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts";
async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs = 10_000, intervalMs = 50) {
@@ -543,8 +544,24 @@ describe("heartbeat comment wake batching", () => {
.values({
companyId,
issueId,
authorType: "user",
authorUserId: "user-1",
body: "Queued follow-up",
presentation: {
kind: "system_notice",
tone: "warning",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
rows: [
{ type: "key_value", label: "Cause", value: "successful_run_missing_state" },
],
},
],
},
})
.returning()
.then((rows) => rows[0]);
@@ -577,7 +594,15 @@ describe("heartbeat comment wake batching", () => {
comments: [
expect.objectContaining({
id: queuedComment.id,
authorType: "user",
body: "Queued follow-up",
presentation: expect.objectContaining({
kind: "system_notice",
tone: "warning",
}),
metadata: expect.objectContaining({
version: 1,
}),
}),
],
commentWindow: {
@@ -1130,6 +1155,7 @@ describe("heartbeat comment wake batching", () => {
expect(payloads).toHaveLength(2);
expect(runs[1]?.contextSnapshot).toMatchObject({
retryReason: "missing_issue_comment",
modelProfile: "cheap",
});
} finally {
gateway.releaseFirstWait();
@@ -1329,8 +1355,9 @@ describe("heartbeat comment wake batching", () => {
eq(agentWakeupRequests.agentId, primaryAgentId),
eq(agentWakeupRequests.reason, "missing_issue_comment"),
),
);
);
expect(missingCommentRetries).toHaveLength(1);
expect(missingCommentRetries[0]?.payload).toMatchObject({ modelProfile: "cheap" });
} finally {
gateway.releaseFirstWait();
await gateway.close();
@@ -1566,7 +1593,8 @@ describe("heartbeat comment wake batching", () => {
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
return runs.length === 1 && runs[0]?.status === "succeeded" && runs[0]?.issueCommentStatus === "satisfied";
const sourceRun = runs.find((run) => run.id === firstRun?.id);
return sourceRun?.status === "succeeded" && sourceRun.issueCommentStatus === "satisfied";
});
const runs = await db
@@ -1574,9 +1602,26 @@ describe("heartbeat comment wake batching", () => {
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
expect(runs[0]?.issueCommentStatus).toBe("satisfied");
expect(runs[0]?.issueCommentSatisfiedByCommentId).not.toBeNull();
const sourceRun = runs.find((run) => run.id === firstRun?.id);
expect(sourceRun?.issueCommentStatus).toBe("satisfied");
expect(sourceRun?.issueCommentSatisfiedByCommentId).not.toBeNull();
await waitFor(async () => {
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId));
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
const hasHandoffComment = comments.some((comment) =>
comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
);
const hasHandoffWake = wakeups.some((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return hasHandoffComment && hasHandoffWake;
});
const comments = await db
.select()
@@ -1584,16 +1629,19 @@ describe("heartbeat comment wake batching", () => {
.where(eq(issueComments.issueId, issueId))
.orderBy(asc(issueComments.createdAt));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toBe("Manual completion comment from the run.");
expect(comments[0]?.createdByRunId).toBe(firstRun?.id);
expect(comments.some((comment) => comment.body === "Manual completion comment from the run.")).toBe(true);
expect(comments.some((comment) =>
comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
)).toBe(true);
expect(comments.every((comment) => !comment.body.startsWith("## Run summary"))).toBe(true);
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
expect(wakeups).toHaveLength(1);
expect(wakeups.some((wakeup) => wakeup.reason === "missing_issue_comment")).toBe(false);
expect(wakeups.some((wakeup) => wakeup.reason === "finish_successful_run_handoff")).toBe(true);
} finally {
gateway.releaseFirstWait();
await gateway.close();
@@ -1,9 +1,133 @@
import { describe, expect, it } from "vitest";
import {
buildPaperclipTaskMarkdown,
mergeCoalescedContextSnapshot,
summarizeHeartbeatRunContextSnapshot,
summarizeHeartbeatRunListResultJson,
} from "../services/heartbeat.js";
describe("buildPaperclipTaskMarkdown", () => {
it("adds planning directives for assignment and comment task context", () => {
const assignment = buildPaperclipTaskMarkdown({
issue: {
id: "issue-1",
identifier: "PAP-3404",
title: "Plan first",
workMode: "planning",
description: null,
},
});
expect(assignment).toContain("- Work mode: \"planning\"");
expect(assignment).toContain("Make the plan only. Do not write code or perform implementation work.");
const commentWake = buildPaperclipTaskMarkdown({
issue: {
id: "issue-1",
identifier: "PAP-3404",
title: "Plan first",
workMode: "planning",
description: null,
},
wakeComment: {
id: "comment-1",
body: "Please revise the plan.",
},
});
expect(commentWake).toContain("Update the plan only. Do not write code or perform implementation work.");
const acceptedConfirmation = buildPaperclipTaskMarkdown({
issue: {
id: "issue-1",
identifier: "PAP-3404",
title: "Plan first",
workMode: "planning",
description: null,
},
interaction: {
kind: "request_confirmation",
status: "accepted",
},
});
expect(acceptedConfirmation).toContain("Create child issues from the approved plan only");
expect(acceptedConfirmation).not.toContain("Make the plan only.");
});
it("prefers ordinary comment planning guidance over stale accepted confirmation state", () => {
const commentWake = buildPaperclipTaskMarkdown({
issue: {
id: "issue-1",
identifier: "PAP-3404",
title: "Plan first",
workMode: "planning",
description: null,
},
wakeComment: {
id: "comment-1",
body: "Please revise the plan.",
},
interaction: {
kind: "request_confirmation",
status: "accepted",
},
});
expect(commentWake).toContain("Update the plan only. Do not write code or perform implementation work.");
expect(commentWake).not.toContain("Create child issues from the approved plan only");
});
});
describe("mergeCoalescedContextSnapshot", () => {
it("clears stale accepted-plan interaction state when merging a later ordinary comment wake", () => {
const merged = mergeCoalescedContextSnapshot(
{
issueId: "issue-1",
interactionId: "interaction-1",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
continuationPolicy: "wake_assignee_on_accept",
wakeReason: "issue_commented",
},
{
issueId: "issue-1",
commentId: "comment-1",
wakeCommentId: "comment-1",
wakeReason: "issue_commented",
},
);
expect(merged.interactionId).toBeUndefined();
expect(merged.interactionKind).toBeUndefined();
expect(merged.interactionStatus).toBeUndefined();
expect(merged.continuationPolicy).toBeUndefined();
expect(merged.commentId).toBe("comment-1");
expect(merged.wakeCommentId).toBe("comment-1");
});
it("preserves accepted-plan interaction state for the interaction wake itself", () => {
const merged = mergeCoalescedContextSnapshot(
{
issueId: "issue-1",
},
{
issueId: "issue-1",
interactionId: "interaction-1",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
continuationPolicy: "wake_assignee_on_accept",
wakeReason: "issue_commented",
},
);
expect(merged.interactionId).toBe("interaction-1");
expect(merged.interactionKind).toBe("request_confirmation");
expect(merged.interactionStatus).toBe("accepted");
expect(merged.continuationPolicy).toBe("wake_assignee_on_accept");
});
});
describe("summarizeHeartbeatRunContextSnapshot", () => {
it("keeps only the small retry/linking fields needed by the client", () => {
const summarized = summarizeHeartbeatRunContextSnapshot({
@@ -11,6 +11,8 @@ import {
createDb,
documentRevisions,
documents,
environmentLeases,
environments,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
@@ -122,6 +124,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 50));
await db.delete(environmentLeases);
await db.delete(activityLog);
await db.delete(companySkills);
await db.delete(issueComments);
@@ -137,6 +140,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(agents);
await db.delete(companySkills);
await db.delete(environments);
await db.delete(companies);
});
@@ -280,6 +285,23 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
unresolvedBlockerIssueIds: [blockerId],
});
let finishReadyRun!: () => void;
const readyRunCanFinish = new Promise<void>((resolve) => {
finishReadyRun = resolve;
});
mockAdapterExecute.mockImplementationOnce(async () => {
await readyRunCanFinish;
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Ready dependency scheduling run complete.",
provider: "test",
model: "test-model",
};
});
const readyWake = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
@@ -288,6 +310,15 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: readyIssueId, wakeReason: "issue_assigned" },
});
expect(readyWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: readyIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: readyWake!.id,
body: "Ready dependency scheduling run complete.",
});
finishReadyRun();
await waitForCondition(async () => {
const run = await db
@@ -354,6 +385,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
expect(promotedBlockedRun?.status).toBe("succeeded");
expect(blockedWakeRequestCount).toBeGreaterThanOrEqual(2);
const noActiveRuns = await waitForCondition(async () => {
const rows = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns);
return rows.every((run) => run.status !== "queued" && run.status !== "running");
}, 10_000);
expect(noActiveRuns).toBe(true);
});
it("honors maxConcurrentRuns 1 by leaving a second assignment wake queued", async () => {
@@ -429,6 +468,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: firstIssueId, wakeReason: "issue_assigned" },
});
expect(firstWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: firstIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: firstWake!.id,
body: "First assignment run completed.",
});
const firstRunStarted = await waitForCondition(async () => {
const run = await db
@@ -439,7 +486,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
return run?.status === "running";
});
expect(firstRunStarted).toBe(true);
const firstAdapterStarted = await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1);
const firstAdapterStarted = await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1, 30_000);
expect(firstAdapterStarted).toBe(true);
const secondWake = await heartbeat.wakeup(agentId, {
@@ -450,6 +497,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: secondIssueId, wakeReason: "issue_assigned" },
});
expect(secondWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: secondIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: secondWake!.id,
body: "Second assignment run completed.",
});
const secondRunWhileFirstRunning = await db
.select({ status: heartbeatRuns.status })
@@ -461,6 +516,16 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
finishFirstRun();
const firstRunSucceeded = await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, firstWake!.id))
.then((rows) => rows[0] ?? null);
return run?.status === "succeeded";
});
expect(firstRunSucceeded).toBe(true);
const secondRunSucceeded = await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
@@ -470,11 +535,11 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
return run?.status === "succeeded";
});
expect(secondRunSucceeded).toBe(true);
expect(mockAdapterExecute).toHaveBeenCalledTimes(2);
expect(mockAdapterExecute.mock.calls.length).toBeGreaterThanOrEqual(2);
} finally {
finishFirstRun();
}
});
}, 40_000);
it("cancels stale queued runs when issue blockers are still unresolved", async () => {
const companyId = randomUUID();
@@ -598,6 +663,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
.update(agentWakeupRequests)
.set({ runId: readyRunId })
.where(eq(agentWakeupRequests.id, readyWakeupRequestId));
await db.insert(issueComments).values({
companyId,
issueId: readyIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: readyRunId,
body: "Ready queued run completed.",
});
await db
.update(issues)
.set({
@@ -665,7 +738,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
executionLockedAt: null,
});
expect(readyRun?.status).toBe("succeeded");
expect(mockAdapterExecute).toHaveBeenCalledTimes(1);
expect(mockAdapterExecute.mock.calls.length).toBeGreaterThanOrEqual(1);
});
it("suppresses normal wakeups while allowing comment interaction wakes under a pause hold", async () => {
@@ -117,7 +117,11 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
});
}
async function seedBlockedChain(opts: { outsideLookback?: boolean } = {}) {
async function seedBlockedChain(opts: {
outsideLookback?: boolean;
blockerStatus?: string;
blockerAssigneeAgentId?: "coder" | "manager" | null;
} = {}) {
const companyId = randomUUID();
const managerId = randomUUID();
const coderId = randomUUID();
@@ -178,8 +182,13 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
id: blockerIssueId,
companyId,
title: "Missing unblock owner",
status: "todo",
status: opts.blockerStatus ?? "todo",
priority: "medium",
assigneeAgentId: opts.blockerAssigneeAgentId === "coder"
? coderId
: opts.blockerAssigneeAgentId === "manager"
? managerId
: null,
issueNumber: 2,
identifier: `${issuePrefix}-2`,
createdAt: issueTimestamp,
@@ -283,6 +292,46 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(result.escalationsCreated).toBe(0);
});
it("creates one bounded escalation for an assigned backlog blocker leaf", async () => {
await enableAutoRecovery();
const { companyId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain({
blockerStatus: "backlog",
blockerAssigneeAgentId: "coder",
});
const heartbeat = heartbeatService(db);
const first = await heartbeat.reconcileIssueGraphLiveness();
const second = await heartbeat.reconcileIssueGraphLiveness();
expect(first.findings).toBe(1);
expect(first.escalationsCreated).toBe(1);
expect(second.findings).toBe(0);
expect(second.escalationsCreated).toBe(0);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(1);
expect(escalations[0]).toMatchObject({
parentId: blockerIssueId,
assigneeAgentId: coderId,
originId: [
"harness_liveness",
companyId,
blockedIssueId,
"blocked_by_assigned_backlog_issue",
blockerIssueId,
].join(":"),
originFingerprint: [
"harness_liveness_leaf",
companyId,
"blocked_by_assigned_backlog_issue",
blockerIssueId,
].join(":"),
});
});
it("creates one manager escalation, preserves blockers, and records owner selection", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
@@ -320,6 +369,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(escalations[0]).toMatchObject({
parentId: blockerIssueId,
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
status: expect.stringMatching(/^(todo|in_progress|done)$/),
originFingerprint: [
"harness_liveness_leaf",
@@ -568,6 +618,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
executionWorkspaceId: null,
executionWorkspacePreference: null,
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
});
@@ -8,6 +8,8 @@ import {
companies,
createDb,
environments,
executionWorkspaces,
issues,
plugins,
projects,
projectWorkspaces,
@@ -17,6 +19,7 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.ts";
const adapterExecute = vi.hoisted(() => vi.fn(async () => ({
@@ -35,6 +38,7 @@ vi.mock("../adapters/index.js", () => ({
execute: adapterExecute,
supportsLocalAgentJwt: false,
}),
listAdapterModelProfiles: async () => [],
runningProcesses: new Map(),
}));
@@ -67,6 +71,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
});
afterAll(async () => {
await db.$client.end();
await stopDb?.();
});
@@ -76,6 +81,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
const workspaceId = randomUUID();
const environmentId = randomUUID();
const pluginId = randomUUID();
const pluginKey = `acme.environments.${pluginId}`;
const agentId = randomUUID();
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-heartbeat-"));
tempRoots.push(workspaceRoot);
@@ -100,6 +106,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
await db.insert(companies).values({
id: companyId,
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
@@ -124,13 +131,13 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.environments",
pluginKey,
packageName: "@acme/paperclip-environments",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.environments",
id: pluginKey,
apiVersion: 1,
version: "1.0.0",
displayName: "Acme Environments",
@@ -157,8 +164,8 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
name: "Plugin Sandbox",
driver: "plugin",
status: "active",
config: {
pluginKey: "acme.environments",
config: {
pluginKey,
driverKey: "sandbox",
driverConfig: {
template: "base",
@@ -199,6 +206,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
driverKey: "sandbox",
companyId,
environmentId,
issueId: null,
config: { template: "base" },
runId: run!.id,
workspaceMode: "shared_workspace",
@@ -208,16 +216,217 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
driverKey: "sandbox",
companyId,
environmentId,
issueId: null,
config: { template: "base" },
providerLeaseId: "plugin-heartbeat-lease",
leaseMetadata: expect.objectContaining({
driver: "plugin",
pluginId,
pluginKey: "acme.environments",
pluginKey,
driverKey: "sandbox",
}),
});
}, { timeout: 5_000 });
expect(adapterExecute).toHaveBeenCalledTimes(1);
});
}, 15_000);
it("ignores stale non-reused workspace environment config in favor of the issue selection", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const workspaceId = randomUUID();
const oldEnvironmentId = randomUUID();
const newEnvironmentId = randomUUID();
const pluginId = randomUUID();
const pluginKey = `acme.environments.${pluginId}`;
const agentId = randomUUID();
const issueId = randomUUID();
const staleExecutionWorkspaceId = randomUUID();
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-issue-"));
tempRoots.push(workspaceRoot);
const workerManager = {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string, payload: Record<string, unknown>) => {
if (method === "environmentAcquireLease") {
return {
providerLeaseId: `plugin-heartbeat-lease-${String(payload.environmentId)}`,
metadata: {
remoteCwd: `/workspace/${String(payload.environmentId)}`,
},
};
}
if (method === "environmentReleaseLease") {
return undefined;
}
throw new Error(`Unexpected plugin environment method: ${method}`);
}),
} as unknown as PluginWorkerManager;
await instanceSettingsService(db).updateExperimental({
enableEnvironments: true,
enableIsolatedWorkspaces: true,
});
await db.insert(companies).values({
id: companyId,
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Plugin Environment Issue",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projectWorkspaces).values({
id: workspaceId,
companyId,
projectId,
name: "Primary",
cwd: workspaceRoot,
isPrimary: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(plugins).values({
id: pluginId,
pluginKey,
packageName: "@acme/paperclip-environments",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: pluginKey,
apiVersion: 1,
version: "1.0.0",
displayName: "Acme Environments",
description: "Test plugin environment driver",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [
{
driverKey: "sandbox",
displayName: "Sandbox",
configSchema: { type: "object" },
},
],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
await db.insert(environments).values([
{
id: oldEnvironmentId,
companyId,
name: "QA SSH",
driver: "plugin",
status: "active",
config: {
pluginKey,
driverKey: "sandbox",
driverConfig: {
template: "old",
},
},
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: newEnvironmentId,
companyId,
name: "QA E2B",
driver: "plugin",
status: "active",
config: {
pluginKey,
driverKey: "sandbox",
driverConfig: {
template: "new",
},
},
createdAt: new Date(),
updatedAt: new Date(),
},
]);
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: oldEnvironmentId,
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(executionWorkspaces).values({
id: staleExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId: workspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Stale workspace",
status: "active",
cwd: workspaceRoot,
providerType: "local_fs",
providerRef: workspaceRoot,
metadata: {
config: {
environmentId: oldEnvironmentId,
},
},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId: workspaceId,
title: "Environment matrix: e2b / codex_local",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
executionWorkspaceId: staleExecutionWorkspaceId,
executionWorkspaceSettings: {
mode: "shared_workspace",
environmentId: newEnvironmentId,
},
createdAt: new Date(),
updatedAt: new Date(),
});
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
const run = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "manual",
contextSnapshot: { issueId },
});
expect(run).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(run!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 5_000 });
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", {
driverKey: "sandbox",
companyId,
environmentId: newEnvironmentId,
issueId,
config: { template: "new" },
runId: run!.id,
workspaceMode: "shared_workspace",
});
}, 15_000);
});
@@ -23,6 +23,7 @@ import {
issueRelations,
issueTreeHoldMembers,
issueTreeHolds,
issueWorkProducts,
issues,
} from "@paperclipai/db";
import {
@@ -69,7 +70,15 @@ vi.mock("../adapters/index.ts", async () => {
};
});
import { heartbeatService } from "../services/heartbeat.ts";
import {
heartbeatService,
redactDetectedSuccessfulRunProgressSummaryForBoard,
} from "../services/heartbeat.ts";
import {
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
} from "../services/recovery/index.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -313,6 +322,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await db.delete(costEvents);
await db.delete(environmentLeases);
await db.delete(environments);
await db.delete(issueWorkProducts);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
@@ -709,6 +719,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
originId: input.issueId,
originRunId: input.runId,
priority: "medium",
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
expect(recovery.title).toContain("Recover stalled issue");
expect(recovery.description).toContain(`Previous source status: \`${input.previousStatus}\``);
@@ -743,6 +754,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
companyId: input.companyId,
reason: "issue_assigned",
source: "assignment",
payload: expect.objectContaining({ modelProfile: "cheap" }),
});
const recoveryRun = recoveryWakeup?.runId
@@ -758,6 +770,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
source: "stranded_issue_recovery",
sourceIssueId: input.issueId,
strandedRunId: input.runId,
modelProfile: "cheap",
});
return recovery;
@@ -915,6 +928,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(retryRun?.status).toBe("queued");
expect(retryRun?.retryOfRunId).toBe(runId);
expect(retryRun?.processLossRetryCount).toBe(1);
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
const issue = await db
.select()
@@ -1227,7 +1241,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect((failedRun?.resultJson as Record<string, unknown> | null)?.errorFamily).toBe("transient_upstream");
expect(retryRun?.status).toBe("scheduled_retry");
expect(retryRun?.scheduledRetryReason).toBe("transient_failure");
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.codexTransientFallbackMode).toBe("same_session");
expect(retryRun?.contextSnapshot).toMatchObject({
codexTransientFallbackMode: "same_session",
modelProfile: "cheap",
});
const issue = await db
.select()
@@ -1241,6 +1258,448 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments).toHaveLength(0);
});
it("queues one finish-handoff wake when a successful run leaves in-progress work without a next action", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Implemented the backend detector, but did not choose a final issue state.",
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Implemented the backend detector, but did not choose a final issue state.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(1);
expect(handoffWakeups[0]?.idempotencyKey).toBe(`finish_successful_run_handoff:${issueId}:${runId}:1`);
expect(handoffWakeups[0]?.payload).toMatchObject({
issueId,
sourceRunId: runId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
handoffAttempt: 1,
maxHandoffAttempts: 1,
resumeIntent: true,
resumeFromRunId: runId,
});
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const handoffComment = comments.find((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
expect(handoffComment).toBeTruthy();
expect(handoffComment?.authorType).toBe("system");
expect(handoffComment?.presentation).toMatchObject({
kind: "system_notice",
tone: "warning",
detailsDefaultOpen: false,
});
expect(handoffComment?.metadata).toMatchObject({
version: 1,
sections: expect.arrayContaining([
expect.objectContaining({
title: "Required action",
rows: expect.arrayContaining([
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "run_link", runId }),
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
]),
}),
]),
});
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
expect(activity.some((event) => event.action === "issue.successful_run_handoff_required")).toBe(true);
});
it("requeues a missing-disposition handoff when the previous corrective wake was cancelled", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
const idempotencyKey = `finish_successful_run_handoff:${issueId}:${runId}:1`;
await db.insert(agentWakeupRequests).values({
id: randomUUID(),
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "finish_successful_run_handoff",
payload: {
issueId,
sourceRunId: runId,
handoffRequired: true,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
},
status: "cancelled",
idempotencyKey,
requestedAt: new Date("2026-03-19T00:00:01.000Z"),
finishedAt: new Date("2026-03-19T00:00:02.000Z"),
updatedAt: new Date("2026-03-19T00:00:02.000Z"),
});
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Implemented recovery handling, but did not choose a final issue state.",
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Implemented recovery handling, but did not choose a final issue state.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.idempotencyKey, idempotencyKey));
const requeued = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return requeued.length > 1 ? requeued : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(2);
expect(handoffWakeups.filter((wakeup) => wakeup.status === "cancelled")).toHaveLength(1);
expect(handoffWakeups.some((wakeup) => wakeup.status !== "cancelled")).toBe(true);
});
it("queues one missing-disposition handoff for artifact-producing successful runs left in progress", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
const documentId = randomUUID();
const revisionId = randomUUID();
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Drafted the Phase 3 test plan but did not choose a final issue disposition.",
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Regression test plan",
format: "markdown",
latestBody: "# Regression test plan\n\n- Cover artifact-producing successful runs",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: agentId,
updatedByAgentId: agentId,
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Regression test plan",
format: "markdown",
body: "# Regression test plan\n\n- Cover artifact-producing successful runs",
createdByAgentId: agentId,
createdByRunId: ctx.runId,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
await db.insert(issueWorkProducts).values({
companyId,
issueId,
type: "report",
provider: "test",
externalId: "phase-3-report",
title: "Phase 3 regression notes",
status: "ready",
summary: "Successful run produced a visible artifact.",
createdByRunId: ctx.runId,
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Created comments, a plan document, and a work product without choosing a disposition.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
const settledRun = await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
const classifiedRun = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
expect(classifiedRun?.status ?? settledRun?.status).toBe("succeeded");
expect(classifiedRun?.livenessState).toBe("advanced");
expect(handoffWakeups).toHaveLength(1);
expect(handoffWakeups[0]?.idempotencyKey).toBe(`finish_successful_run_handoff:${issueId}:${runId}:1`);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("in_progress");
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([]);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments.filter((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toHaveLength(1);
expect(comments.some((comment) => comment.body.startsWith("Drafted the Phase 3 test plan"))).toBe(true);
const workProducts = await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.issueId, issueId));
expect(workProducts).toHaveLength(1);
const recoveryIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
expect(recoveryIssues).toHaveLength(0);
});
it("redacts secret-bearing successful-run detected progress before handoff disclosure", async () => {
const { agentId, runId, issueId } = await seedQueuedIssueRunFixture();
const bearerSecret = "live-bearer-token-value";
const apiKeySecret = "sk-testsuccessfulhandoffsecret";
const redactedDetectedSummary = redactDetectedSuccessfulRunProgressSummaryForBoard(
`Next action noted: Authorization: Bearer ${bearerSecret} OPENAI_API_KEY=${apiKeySecret}`,
{ enabled: false },
);
expect(redactedDetectedSummary).toContain("***REDACTED***");
expect(redactedDetectedSummary).not.toContain(bearerSecret);
expect(redactedDetectedSummary).not.toContain(apiKeySecret);
mockAdapterExecute.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Made progress but left the issue open.",
resultJson: {
message: `Next action: Authorization: Bearer ${bearerSecret} OPENAI_API_KEY=${apiKeySecret}`,
},
provider: "test",
model: "test-model",
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(1);
const wakeupPayloadText = JSON.stringify(handoffWakeups[0]?.payload ?? {});
expect(wakeupPayloadText).not.toContain(bearerSecret);
expect(wakeupPayloadText).not.toContain(apiKeySecret);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const handoffComment = comments.find((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
expect(handoffComment).toBeTruthy();
expect(handoffComment?.body).not.toContain(bearerSecret);
expect(handoffComment?.body).not.toContain(apiKeySecret);
expect(JSON.stringify(handoffComment?.metadata ?? {})).not.toContain(bearerSecret);
expect(JSON.stringify(handoffComment?.metadata ?? {})).not.toContain(apiKeySecret);
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
const handoffActivity = activity.find((event) => event.action === "issue.successful_run_handoff_required");
expect(handoffActivity).toBeTruthy();
const activityDetailsText = JSON.stringify(handoffActivity?.details ?? {});
expect(activityDetailsText).not.toContain(bearerSecret);
expect(activityDetailsText).not.toContain(apiKeySecret);
});
it("escalates an exhausted failed successful-run handoff without using generic continuation recovery first", async () => {
const { companyId, agentId, runId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
runErrorCode: "adapter_failed",
runError: "Authorization: Bearer sk-test-successful-handoff-secret",
});
const sourceRunId = randomUUID();
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "finish_successful_run_handoff",
sourceRunId,
resumeFromRunId: sourceRunId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
missingDisposition: "clear_next_step",
handoffAttempt: 1,
maxHandoffAttempts: 1,
},
})
.where(eq(heartbeatRuns.id, runId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(0);
expect(result.successfulRunHandoffEscalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const recovery = await waitForValue(async () =>
db.select().from(issues).where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "stranded_issue_recovery"),
eq(issues.originId, issueId),
),
).then((rows) => rows[0] ?? null),
);
expect(recovery?.assigneeAgentId).toBe(agentId);
expect(recovery?.title).toContain("Recover missing next step");
expect(recovery?.description).toContain("Normalized cause: `successful_run_missing_state`");
expect(recovery?.description).toContain("not a runtime/adapter crash report");
expect(recovery?.description).toContain(`Source run: [\`${sourceRunId}\`]`);
expect(recovery?.description).toContain("Missing disposition: `clear_next_step`");
expect(recovery?.description).toContain("Source assignee: [CodexCoder]");
expect(recovery?.description).not.toContain("sk-test-successful-handoff-secret");
const sourceIssue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(sourceIssue?.status).toBe("blocked");
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([recovery?.id]);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments[0]?.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
expect(comments[0]?.authorType).toBe("system");
expect(comments[0]?.presentation).toMatchObject({
kind: "system_notice",
tone: "danger",
detailsDefaultOpen: false,
});
expect(comments[0]?.metadata).toMatchObject({
version: 1,
sections: expect.arrayContaining([
expect.objectContaining({
title: "Recovery owner",
rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: recovery?.identifier }),
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CodexCoder" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
]),
});
expect(comments[0]?.body).not.toContain("sk-test-successful-handoff-secret");
expect(JSON.stringify(comments[0]?.metadata ?? {})).not.toContain("sk-test-successful-handoff-secret");
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId));
expect(activity.some((event) => event.action === "issue.successful_run_handoff_escalated")).toBe(true);
});
it("escalates an exhausted successful handoff run that still leaves no disposition", async () => {
const { companyId, runId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "succeeded",
livenessState: "advanced",
});
const sourceRunId = randomUUID();
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "finish_successful_run_handoff",
sourceRunId,
resumeFromRunId: sourceRunId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
missingDisposition: "clear_next_step",
handoffAttempt: 1,
maxHandoffAttempts: 1,
},
})
.where(eq(heartbeatRuns.id, runId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.successfulContinuationObserved).toBe(0);
expect(result.successfulRunHandoffEscalated).toBe(1);
const recovery = await waitForValue(async () =>
db.select().from(issues).where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "stranded_issue_recovery"),
eq(issues.originId, issueId),
),
).then((rows) => rows[0] ?? null),
);
expect(recovery?.description).toContain("Latest handoff run status: `succeeded`");
expect(recovery?.description).toContain("Suggested");
});
it("clears the detached warning when the run reports activity again", async () => {
const { runId } = await seedRunFixture({
includeIssue: false,
@@ -1315,6 +1774,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
payload: expect.objectContaining({
issueId,
mutation: "assigned_todo_liveness_dispatch",
modelProfile: "cheap",
}),
});
@@ -1326,6 +1786,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
taskId: issueId,
wakeReason: "issue_assigned",
source: "issue.assigned_todo_liveness_dispatch",
modelProfile: "cheap",
});
expect((runs[0]?.contextSnapshot as Record<string, unknown>)?.retryReason).toBeUndefined();
@@ -1433,6 +1894,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
payload: expect.objectContaining({
issueId: unblocked.issueId,
mutation: "assigned_todo_liveness_dispatch",
modelProfile: "cheap",
}),
});
const unblockedRuns = await db
@@ -1486,6 +1948,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -1524,6 +1987,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.continuation_recovery",
modelProfile: "cheap",
});
const recoveries = await db
@@ -1575,6 +2039,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -1615,6 +2080,83 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`);
});
it("blocks an already stranded recovery issue without creating a recovery child", async () => {
const { companyId, issueId } = await seedStrandedIssueFixture({
status: "todo",
runStatus: "failed",
retryReason: "assignment_recovery",
});
const sourceIssueId = randomUUID();
const sourceRunId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(issues).values({
id: sourceIssueId,
companyId,
title: "Original source issue",
status: "blocked",
priority: "medium",
issueNumber: 2,
identifier: `${issuePrefix}-2`,
});
await db
.update(issues)
.set({
title: "Recover stalled issue from previous adapter failure",
parentId: sourceIssueId,
originKind: "stranded_issue_recovery",
originId: sourceIssueId,
originRunId: sourceRunId,
originFingerprint: [
"stranded_issue_recovery",
companyId,
sourceIssueId,
sourceRunId,
].join(":"),
})
.where(eq(issues.id, issueId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.dispatchRequeued).toBe(0);
expect(result.escalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const recoveryIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
expect(recoveryIssues).toHaveLength(1);
expect(recoveryIssues[0]).toMatchObject({
id: issueId,
status: "blocked",
parentId: sourceIssueId,
originId: sourceIssueId,
originRunId: sourceRunId,
});
expect(recoveryIssues[0]?.checkoutRunId).toBeNull();
expect(recoveryIssues[0]?.executionRunId).toBeNull();
const blockerRelations = await db
.select()
.from(issueRelations)
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.relatedIssueId, issueId),
eq(issueRelations.type, "blocks"),
),
);
expect(blockerRelations).toHaveLength(0);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("stopped automatic stranded-work recovery");
expect(comments[0]?.body).toContain("recovery issues do not create nested `stranded_issue_recovery` issues");
expect(comments[0]?.body).toContain(`Recovery issue: [${recoveryIssues[0]?.identifier}]`);
expect(comments[0]?.body).toContain("Next action:");
});
it("assigns open unassigned blockers back to their creator agent", async () => {
const companyId = randomUUID();
const creatorAgentId = randomUUID();
@@ -1738,6 +2280,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("issue_continuation_needed");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -2215,6 +2758,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
@@ -2281,6 +2825,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
});
@@ -2336,6 +2881,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
});
@@ -17,6 +17,17 @@ describe("resolveExecutionRunAdapterConfig", () => {
other: "value",
},
secretKeys: new Set(["AGENT_SECRET"]),
manifest: [
{
configPath: "env.AGENT_SECRET",
envKey: "AGENT_SECRET",
secretId: "secret-agent",
secretKey: "agent-secret",
version: 1,
provider: "local_encrypted",
outcome: "success",
},
],
});
const resolveEnvBindings = vi.fn().mockResolvedValue({
env: {
@@ -24,6 +35,17 @@ describe("resolveExecutionRunAdapterConfig", () => {
PROJECT_ONLY: "project-only",
},
secretKeys: new Set(["PROJECT_SECRET"]),
manifest: [
{
configPath: "env.PROJECT_SECRET",
envKey: "PROJECT_SECRET",
secretId: "secret-project",
secretKey: "project-secret",
version: 1,
provider: "local_encrypted",
outcome: "success",
},
],
});
const result = await resolveExecutionRunAdapterConfig({
@@ -45,12 +67,19 @@ describe("resolveExecutionRunAdapterConfig", () => {
},
});
expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]);
expect(result.secretManifest.map((entry) => entry.secretId).sort()).toEqual([
"secret-agent",
"secret-project",
]);
expect(JSON.stringify(result.secretManifest)).not.toContain("agent-only");
expect(JSON.stringify(result.secretManifest)).not.toContain("project-only");
});
it("skips project env resolution when the project has no bindings", async () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: { env: { AGENT_ONLY: "agent-only" } },
secretKeys: new Set<string>(),
manifest: [],
});
const resolveEnvBindings = vi.fn();
@@ -65,6 +94,7 @@ describe("resolveExecutionRunAdapterConfig", () => {
});
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" });
expect(result.secretManifest).toEqual([]);
expect(resolveEnvBindings).not.toHaveBeenCalled();
});
});
@@ -1,15 +1,17 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
agentRuntimeState,
agentWakeupRequests,
budgetPolicies,
companies,
createDb,
environmentLeases,
heartbeatRunEvents,
heartbeatRuns,
issueRelations,
issues,
} from "@paperclipai/db";
import {
@@ -18,6 +20,8 @@ import {
} from "./helpers/embedded-postgres.js";
import {
BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS,
MAX_TURN_CONTINUATION_RETRY_REASON,
MAX_TURN_CONTINUATION_WAKE_REASON,
heartbeatService,
} from "../services/heartbeat.ts";
@@ -44,10 +48,12 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
afterEach(async () => {
await db.delete(heartbeatRunEvents);
await db.delete(environmentLeases);
await db.delete(issueRelations);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(budgetPolicies);
await db.delete(agents);
await db.delete(companies);
});
@@ -124,6 +130,92 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
});
}
async function seedMaxTurnFixture(input?: {
companyId?: string;
agentId?: string;
issueId?: string;
runId?: string;
now?: Date;
scheduledRetryAttempt?: number;
runtimeConfig?: Record<string, unknown>;
issueStatus?: string;
}) {
const companyId = input?.companyId ?? randomUUID();
const agentId = input?.agentId ?? randomUUID();
const issueId = input?.issueId ?? randomUUID();
const runId = input?.runId ?? randomUUID();
const now = input?.now ?? new Date("2026-04-20T12:00:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "ClaudeCoder",
role: "engineer",
status: "active",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: input?.runtimeConfig ?? {
heartbeat: {
wakeOnDemand: true,
maxConcurrentRuns: 1,
maxTurnContinuation: {
enabled: true,
maxAttempts: 2,
delayMs: 1_000,
},
},
},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
triggerDetail: "system",
status: "failed",
error: "Maximum turns reached",
errorCode: "adapter_failed",
finishedAt: now,
scheduledRetryAttempt: input?.scheduledRetryAttempt ?? 0,
scheduledRetryReason: input?.scheduledRetryAttempt ? MAX_TURN_CONTINUATION_RETRY_REASON : null,
resultJson: {
stopReason: "max_turns_exhausted",
},
contextSnapshot: {
issueId,
wakeReason: "issue_assigned",
},
updatedAt: now,
createdAt: now,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Continue after max turns",
status: input?.issueStatus ?? "in_progress",
priority: "medium",
assigneeAgentId: agentId,
executionRunId: runId,
executionAgentNameKey: "claudecoder",
executionLockedAt: now,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
return { companyId, agentId, issueId, runId, now };
}
it("schedules a retry with durable metadata and only promotes it when due", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -194,6 +286,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
retryOfRunId: sourceRunId,
scheduledRetryAttempt: 1,
scheduledRetryReason: "transient_failure",
contextSnapshot: expect.objectContaining({ modelProfile: "cheap" }),
});
expect(retryRun?.scheduledRetryAt?.toISOString()).toBe(expectedDueAt.toISOString());
@@ -218,6 +311,416 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
expect(promotedRun?.status).toBe("queued");
});
it("schedules max-turn continuations with distinct retry metadata", async () => {
const { runId, now } = await seedMaxTurnFixture();
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
now,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
maxAttempts: 2,
delayMs: 1_000,
});
expect(scheduled.outcome).toBe("scheduled");
if (scheduled.outcome !== "scheduled") return;
expect(scheduled.attempt).toBe(1);
expect(scheduled.dueAt.toISOString()).toBe(new Date(now.getTime() + 1_000).toISOString());
const retryRun = await db
.select({
retryOfRunId: heartbeatRuns.retryOfRunId,
status: heartbeatRuns.status,
scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt,
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
contextSnapshot: heartbeatRuns.contextSnapshot,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, scheduled.run.id))
.then((rows) => rows[0] ?? null);
expect(retryRun).toMatchObject({
retryOfRunId: runId,
status: "scheduled_retry",
scheduledRetryAttempt: 1,
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
});
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.wakeReason).toBe(
MAX_TURN_CONTINUATION_WAKE_REASON,
);
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.codexTransientFallbackMode ?? null).toBeNull();
const wakeupRequest = await db
.select({ reason: agentWakeupRequests.reason, payload: agentWakeupRequests.payload })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? ""))
.then((rows) => rows[0] ?? null);
expect(wakeupRequest?.reason).toBe(MAX_TURN_CONTINUATION_WAKE_REASON);
expect(wakeupRequest?.payload).toMatchObject({
retryOfRunId: runId,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
scheduledRetryAttempt: 1,
});
});
it("coalesces duplicate max-turn continuation schedules for the same source run and attempt", async () => {
const { issueId, runId, now } = await seedMaxTurnFixture();
const retryOptions = {
now,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
maxAttempts: 2,
delayMs: 1_000,
};
const [first, second] = await Promise.all([
heartbeat.scheduleBoundedRetry(runId, retryOptions),
heartbeat.scheduleBoundedRetry(runId, retryOptions),
]);
expect(first.outcome).toBe("scheduled");
expect(second.outcome).toBe("scheduled");
if (first.outcome !== "scheduled" || second.outcome !== "scheduled") return;
expect(new Set([first.run.id, second.run.id]).size).toBe(1);
const retryRuns = await db
.select({
id: heartbeatRuns.id,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.retryOfRunId, runId),
eq(heartbeatRuns.scheduledRetryReason, MAX_TURN_CONTINUATION_RETRY_REASON),
eq(heartbeatRuns.scheduledRetryAttempt, 1),
),
);
expect(retryRuns).toHaveLength(1);
const wakeups = await db
.select({
id: agentWakeupRequests.id,
coalescedCount: agentWakeupRequests.coalescedCount,
idempotencyKey: agentWakeupRequests.idempotencyKey,
})
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.reason, MAX_TURN_CONTINUATION_WAKE_REASON));
expect(wakeups).toHaveLength(1);
expect(wakeups[0]).toMatchObject({
id: retryRuns[0]?.wakeupRequestId,
coalescedCount: 1,
});
expect(wakeups[0]?.idempotencyKey).toContain(`:${issueId}:${runId}:1`);
const issue = await db
.select({ executionRunId: issues.executionRunId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issue?.executionRunId).toBe(retryRuns[0]?.id);
});
it("does not promote a duplicate max-turn continuation that does not own the issue lock", async () => {
const { companyId, agentId, issueId, runId, now } = await seedMaxTurnFixture();
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
now,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
maxAttempts: 2,
delayMs: 1_000,
});
expect(scheduled.outcome).toBe("scheduled");
if (scheduled.outcome !== "scheduled") return;
const duplicateWakeupId = randomUUID();
const duplicateRunId = randomUUID();
await db.insert(agentWakeupRequests).values({
id: duplicateWakeupId,
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: MAX_TURN_CONTINUATION_WAKE_REASON,
payload: {
issueId,
retryOfRunId: runId,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
scheduledRetryAttempt: 1,
},
status: "queued",
requestedByActorType: "system",
});
await db.insert(heartbeatRuns).values({
id: duplicateRunId,
companyId,
agentId,
invocationSource: "automation",
triggerDetail: "system",
status: "scheduled_retry",
wakeupRequestId: duplicateWakeupId,
retryOfRunId: runId,
scheduledRetryAt: scheduled.dueAt,
scheduledRetryAttempt: 1,
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
contextSnapshot: {
issueId,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
},
});
await db
.update(agentWakeupRequests)
.set({ runId: duplicateRunId })
.where(eq(agentWakeupRequests.id, duplicateWakeupId));
const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt);
expect(promotion).toEqual({ promoted: 1, runIds: [scheduled.run.id] });
const duplicate = await db
.select({
status: heartbeatRuns.status,
errorCode: heartbeatRuns.errorCode,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, duplicateRunId))
.then((rows) => rows[0] ?? null);
expect(duplicate).toEqual({
status: "cancelled",
errorCode: "issue_execution_lock_changed",
});
const duplicateWakeup = await db
.select({ status: agentWakeupRequests.status })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, duplicateWakeupId))
.then((rows) => rows[0] ?? null);
expect(duplicateWakeup?.status).toBe("cancelled");
});
it.each(["blocked", "todo", "backlog"] as const)(
"does not schedule a max-turn continuation when the issue is already %s",
async (issueStatus) => {
const { issueId, runId, now } = await seedMaxTurnFixture({ issueStatus });
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
now,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
maxAttempts: 2,
delayMs: 1_000,
});
expect(scheduled).toMatchObject({
outcome: "not_scheduled",
errorCode: "issue_not_in_progress",
issueId,
});
const retryRuns = await db
.select({ count: sql<number>`count(*)::int` })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.retryOfRunId, runId))
.then((rows) => rows[0]?.count ?? 0);
expect(retryRuns).toBe(0);
},
);
it.each(["blocked", "todo", "backlog"] as const)(
"cancels a due max-turn continuation when the issue moves to %s before retry promotion",
async (issueStatus) => {
const { issueId, runId, now } = await seedMaxTurnFixture();
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
now,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
maxAttempts: 2,
delayMs: 1_000,
});
expect(scheduled.outcome).toBe("scheduled");
if (scheduled.outcome !== "scheduled") return;
await db.update(issues).set({
status: issueStatus,
updatedAt: new Date(now.getTime() + 500),
}).where(eq(issues.id, issueId));
const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt);
expect(promotion).toEqual({ promoted: 0, runIds: [] });
const retryRun = await db
.select({
status: heartbeatRuns.status,
errorCode: heartbeatRuns.errorCode,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, scheduled.run.id))
.then((rows) => rows[0] ?? null);
expect(retryRun).toMatchObject({
status: "cancelled",
errorCode: "issue_not_in_progress",
});
const wakeupRequest = await db
.select({ status: agentWakeupRequests.status })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? ""))
.then((rows) => rows[0] ?? null);
expect(wakeupRequest?.status).toBe("cancelled");
const issue = await db
.select({
executionRunId: issues.executionRunId,
executionAgentNameKey: issues.executionAgentNameKey,
executionLockedAt: issues.executionLockedAt,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issue).toEqual({
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
});
const event = await db
.select({
message: heartbeatRunEvents.message,
payload: heartbeatRunEvents.payload,
})
.from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, scheduled.run.id))
.orderBy(sql`${heartbeatRunEvents.seq} desc`)
.then((rows) => rows[0] ?? null);
expect(event?.message).toContain("no longer in_progress");
expect(event?.payload).toMatchObject({
currentStatus: issueStatus,
requiredStatus: "in_progress",
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
});
},
);
it("does not queue max-turn continuations after the configured cap", async () => {
const { runId, now } = await seedMaxTurnFixture({ scheduledRetryAttempt: 2 });
const exhausted = await heartbeat.scheduleBoundedRetry(runId, {
now,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
maxAttempts: 2,
delayMs: 1_000,
});
expect(exhausted).toEqual({
outcome: "retry_exhausted",
attempt: 3,
maxAttempts: 2,
});
const runCount = await db
.select({ count: sql<number>`count(*)::int` })
.from(heartbeatRuns)
.then((rows) => rows[0]?.count ?? 0);
expect(runCount).toBe(1);
const exhaustionEvent = await db
.select({ message: heartbeatRunEvents.message, payload: heartbeatRunEvents.payload })
.from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, runId))
.orderBy(sql`${heartbeatRunEvents.id} desc`)
.then((rows) => rows[0] ?? null);
expect(exhaustionEvent?.message).toContain("Bounded retry exhausted");
expect(exhaustionEvent?.payload).toMatchObject({
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
maxAttempts: 2,
});
});
it("suppresses max-turn continuation scheduling when budget or dependencies block the issue", async () => {
const budgetBlocked = await seedMaxTurnFixture({ now: new Date("2026-04-20T16:00:00.000Z") });
await db.insert(budgetPolicies).values({
companyId: budgetBlocked.companyId,
scopeType: "agent",
scopeId: budgetBlocked.agentId,
windowKind: "monthly",
metric: "billed_cents",
amount: 0,
hardStopEnabled: true,
isActive: true,
});
await db
.update(agents)
.set({ status: "paused", pauseReason: "budget" })
.where(eq(agents.id, budgetBlocked.agentId));
const budgetResult = await heartbeat.scheduleBoundedRetry(budgetBlocked.runId, {
now: budgetBlocked.now,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
maxAttempts: 2,
delayMs: 1_000,
});
expect(budgetResult).toMatchObject({
outcome: "not_scheduled",
errorCode: "budget_blocked",
issueId: budgetBlocked.issueId,
});
await db.delete(budgetPolicies);
await db.delete(issueRelations);
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(agents);
await db.delete(companies);
const dependencyBlocked = await seedMaxTurnFixture({ now: new Date("2026-04-20T17:00:00.000Z") });
const blockerId = randomUUID();
await db.insert(issues).values({
id: blockerId,
companyId: dependencyBlocked.companyId,
title: "Blocker",
status: "todo",
priority: "medium",
issueNumber: 2,
identifier: `T${dependencyBlocked.companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-2`,
});
await db.insert(issueRelations).values({
companyId: dependencyBlocked.companyId,
issueId: blockerId,
relatedIssueId: dependencyBlocked.issueId,
type: "blocks",
});
const dependencyResult = await heartbeat.scheduleBoundedRetry(dependencyBlocked.runId, {
now: dependencyBlocked.now,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
maxAttempts: 2,
delayMs: 1_000,
});
expect(dependencyResult).toMatchObject({
outcome: "not_scheduled",
errorCode: "issue_dependencies_blocked",
issueId: dependencyBlocked.issueId,
});
const retryRuns = await db
.select({ count: sql<number>`count(*)::int` })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.retryOfRunId, dependencyBlocked.runId))
.then((rows) => rows[0]?.count ?? 0);
expect(retryRuns).toBe(0);
});
it("does not defer a new assignee behind the previous assignee's scheduled retry", async () => {
const companyId = randomUUID();
const oldAgentId = randomUUID();
@@ -19,11 +19,16 @@ import {
issueTreeHolds,
issues,
} from "@paperclipai/db";
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import {
MAX_TURN_CONTINUATION_RETRY_REASON,
MAX_TURN_CONTINUATION_WAKE_REASON,
heartbeatService,
} from "../services/heartbeat.ts";
import { runningProcesses } from "../adapters/index.ts";
const mockAdapterExecute = vi.hoisted(() =>
@@ -83,6 +88,40 @@ async function waitForCondition(fn: () => Promise<boolean>, timeoutMs = 3_000) {
return fn();
}
async function cleanupHeartbeatInvalidationFixture(db: ReturnType<typeof createDb>) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await db.delete(companySkills);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(issueTreeHolds);
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(agents);
await db.delete(companies);
return;
} catch (error) {
const isLateCommentRace =
error instanceof Error &&
error.message.includes("issue_comments_issue_id_issues_id_fk");
if (!isLateCommentRace || attempt === 4) {
throw error;
}
// Heartbeat completion can write issue-thread comments shortly after the
// run leaves queued/running. Retry the dependent deletes once those land.
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
}
type SeedOptions = {
agentName?: string;
agentRole?: string;
@@ -99,6 +138,9 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
let heartbeat!: ReturnType<typeof heartbeatService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const countExecuteCallsForRun = (runId: string) =>
mockAdapterExecute.mock.calls.filter(([context]) => context?.runId === runId).length;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-stale-queue-");
db = createDb(tempDb.connectionString);
@@ -133,21 +175,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 50));
await db.delete(companySkills);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(issueTreeHolds);
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(agents);
await db.delete(companies);
await cleanupHeartbeatInvalidationFixture(db);
});
afterAll(async () => {
@@ -189,6 +217,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
wakeReason: string;
contextExtras?: Record<string, unknown>;
invocationSource?: "assignment" | "automation";
scheduledRetryReason?: string | null;
}) {
const wakeupRequestId = randomUUID();
const runId = randomUUID();
@@ -210,6 +239,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
triggerDetail: "system",
status: "queued",
wakeupRequestId,
scheduledRetryReason: input.scheduledRetryReason ?? null,
contextSnapshot: {
issueId: input.issueId,
wakeReason: input.wakeReason,
@@ -223,6 +253,43 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
return { runId, wakeupRequestId };
}
async function seedContinuationSummary(input: {
companyId: string;
issueId: string;
agentId: string;
body: string;
}) {
const documentId = randomUUID();
const revisionId = randomUUID();
await db.insert(documents).values({
id: documentId,
companyId: input.companyId,
title: "Continuation Summary",
format: "markdown",
latestBody: input.body,
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: input.agentId,
updatedByAgentId: input.agentId,
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId: input.companyId,
documentId,
revisionNumber: 1,
title: "Continuation Summary",
format: "markdown",
body: input.body,
createdByAgentId: input.agentId,
});
await db.insert(issueDocuments).values({
companyId: input.companyId,
issueId: input.issueId,
documentId,
key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
});
}
it("cancels queued runs when the issue assignee changes before the run starts", async () => {
const { companyId, agentId } = await seedCompanyAndAgent({ agentName: "OriginalCoder" });
const replacementAgentId = randomUUID();
@@ -293,7 +360,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
expect(run?.resultJson).toMatchObject({ stopReason: "issue_assignee_changed" });
expect(wakeup?.status).toBe("skipped");
expect(wakeup?.error).toContain("assignee changed");
expect(mockAdapterExecute).not.toHaveBeenCalled();
expect(countExecuteCallsForRun(runId)).toBe(0);
});
it("cancels queued runs when the issue reaches a terminal status before the run starts", async () => {
@@ -342,7 +409,155 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
expect(run?.status).toBe("cancelled");
expect(run?.errorCode).toBe("issue_terminal_status");
expect(wakeup?.status).toBe("skipped");
expect(mockAdapterExecute).not.toHaveBeenCalled();
expect(countExecuteCallsForRun(runId)).toBe(0);
});
it("cancels queued max-turn continuations when the issue is no longer in_progress before the run starts", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Parked max-turn continuation",
status: "blocked",
priority: "medium",
assigneeAgentId: agentId,
});
const { runId, wakeupRequestId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
invocationSource: "automation",
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
contextExtras: {
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
},
});
await heartbeat.resumeQueuedRuns();
await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run?.status === "cancelled";
});
const [run, wakeup] = await Promise.all([
db
.select({
status: heartbeatRuns.status,
errorCode: heartbeatRuns.errorCode,
resultJson: heartbeatRuns.resultJson,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null),
db
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null),
]);
expect(run?.status).toBe("cancelled");
expect(run?.errorCode).toBe("issue_not_in_progress");
expect(run?.resultJson).toMatchObject({ stopReason: "issue_not_in_progress" });
expect(wakeup?.status).toBe("skipped");
expect(wakeup?.error).toContain("no longer in_progress");
expect(countExecuteCallsForRun(runId)).toBe(0);
});
it("cancels queued max-turn continuations when another continuation owns the issue lock", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
const lockOwnerRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: lockOwnerRunId,
companyId,
agentId,
invocationSource: "automation",
triggerDetail: "system",
status: "scheduled_retry",
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
scheduledRetryAttempt: 1,
scheduledRetryAt: new Date("2026-04-20T12:00:00.000Z"),
contextSnapshot: {
issueId,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Duplicate max-turn continuation",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
executionRunId: lockOwnerRunId,
executionAgentNameKey: "claudecoder",
executionLockedAt: new Date("2026-04-20T11:59:00.000Z"),
});
const { runId, wakeupRequestId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
invocationSource: "automation",
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
contextExtras: {
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
},
});
await heartbeat.resumeQueuedRuns();
await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run?.status === "cancelled";
});
const [run, wakeup, issue] = await Promise.all([
db
.select({
status: heartbeatRuns.status,
errorCode: heartbeatRuns.errorCode,
resultJson: heartbeatRuns.resultJson,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null),
db
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null),
db
.select({ executionRunId: issues.executionRunId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null),
]);
expect(run?.status).toBe("cancelled");
expect(run?.errorCode).toBe("issue_execution_lock_changed");
expect(run?.resultJson).toMatchObject({ stopReason: "issue_execution_lock_changed" });
expect(wakeup?.status).toBe("skipped");
expect(wakeup?.error).toContain("execution lock");
expect(issue?.executionRunId).toBe(lockOwnerRunId);
expect(countExecuteCallsForRun(runId)).toBe(0);
});
it("cancels queued in_review runs when the current participant changes before the run starts", async () => {
@@ -422,7 +637,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
expect(run?.resultJson).toMatchObject({ stopReason: "issue_review_participant_changed" });
expect(wakeup?.status).toBe("skipped");
expect(wakeup?.error).toContain("in-review participant changed");
expect(mockAdapterExecute).not.toHaveBeenCalled();
expect(countExecuteCallsForRun(runId)).toBe(0);
});
it("still runs comment-driven wakes on in_review issues even when the agent is no longer the current participant", async () => {
@@ -540,6 +755,77 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
.then((rows) => rows[0] ?? null);
expect(run?.status).toBe("succeeded");
expect(run?.errorCode).toBeNull();
expect(mockAdapterExecute).toHaveBeenCalledTimes(1);
expect(countExecuteCallsForRun(runId)).toBe(1);
});
it("cancels queued continuation recovery when the continuation summary parks executor work for review", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Implementation parked for review",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
await seedContinuationSummary({
companyId,
issueId,
agentId,
body: [
"# Continuation Summary",
"",
"## Next Action",
"",
"- Wait for reviewer feedback or approval before continuing executor work.",
].join("\n"),
});
const { runId, wakeupRequestId } = await seedQueuedRun({
companyId,
agentId,
issueId,
wakeReason: "issue_continuation_needed",
invocationSource: "automation",
contextExtras: {
retryReason: "issue_continuation_needed",
},
});
await heartbeat.resumeQueuedRuns();
await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return run?.status === "cancelled";
});
const [run, wakeup] = await Promise.all([
db
.select({
status: heartbeatRuns.status,
errorCode: heartbeatRuns.errorCode,
resultJson: heartbeatRuns.resultJson,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null),
db
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null),
]);
expect(run?.status).toBe("cancelled");
expect(run?.errorCode).toBe("issue_continuation_waiting_on_review");
expect(run?.resultJson).toMatchObject({ stopReason: "issue_continuation_waiting_on_review" });
expect(wakeup?.status).toBe("skipped");
expect(wakeup?.error).toContain("continuation summary says the executor should wait");
expect(countExecuteCallsForRun(runId)).toBe(0);
});
});
@@ -1,5 +1,6 @@
import express from "express";
import request from "supertest";
import { getTableName } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
@@ -109,7 +110,7 @@ function registerModuleMocks() {
}));
}
async function createApp() {
async function createApp(db: unknown = {}) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
@@ -126,7 +127,7 @@ async function createApp() {
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(db as any, {} as any));
app.use(errorHandler);
return app;
}
@@ -266,6 +267,158 @@ describe("issue activity event routes", () => {
});
}, 15_000);
it("logs readable workspace change activity details for issue updates", async () => {
const previousProjectWorkspaceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const nextExecutionWorkspaceId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const issue = {
...makeIssue(),
projectId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
projectWorkspaceId: previousProjectWorkspaceId,
executionWorkspaceId: null,
executionWorkspacePreference: "shared_workspace",
executionWorkspaceSettings: { mode: "shared_workspace" },
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const dbMock = {
select: vi.fn(() => ({
from: (table: unknown) => ({
where: async () => {
const tableName = getTableName(table as Parameters<typeof getTableName>[0]);
if (tableName === "project_workspaces") {
return [{ id: previousProjectWorkspaceId, name: "Main workspace" }];
}
if (tableName === "execution_workspaces") {
return [{ id: nextExecutionWorkspaceId, name: "Feature workspace" }];
}
return [];
},
}),
})),
};
const res = await request(await createApp(dbMock))
.patch(`/api/issues/${issue.id}`)
.send({ executionWorkspaceId: nextExecutionWorkspaceId });
expect(res.status).toBe(200);
await vi.waitFor(() => {
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
executionWorkspaceId: nextExecutionWorkspaceId,
workspaceChange: {
from: {
label: "Main workspace",
projectWorkspaceId: previousProjectWorkspaceId,
executionWorkspaceId: null,
mode: "shared_workspace",
},
to: {
label: "Feature workspace",
projectWorkspaceId: previousProjectWorkspaceId,
executionWorkspaceId: nextExecutionWorkspaceId,
mode: "shared_workspace",
},
},
_previous: expect.objectContaining({
executionWorkspaceId: null,
}),
}),
}),
);
});
});
it("logs successful_run_handoff_resolved when an in_progress issue transitions to done with a pending required handoff", async () => {
const issue = { ...makeIssue(), status: "in_progress" };
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const handoffActivityRow = {
entityId: issue.id,
action: "issue.successful_run_handoff_required",
agentId: issue.assigneeAgentId,
runId: "run-1",
details: {
sourceRunId: "run-1",
correctiveRunId: "run-2",
},
createdAt: new Date("2026-05-01T00:00:00.000Z"),
};
const dbMock = {
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => [handoffActivityRow],
}),
}),
}),
};
const res = await request(await createApp(dbMock))
.patch(`/api/issues/${issue.id}`)
.send({ status: "done" });
expect(res.status).toBe(200);
await vi.waitFor(() => {
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.successful_run_handoff_resolved",
entityId: issue.id,
details: expect.objectContaining({
identifier: "PAP-580",
sourceRunId: "run-1",
correctiveRunId: "run-2",
resolvedByStatus: "done",
}),
}),
);
});
});
it("does not log successful_run_handoff_resolved when status stays in_progress", async () => {
const issue = { ...makeIssue(), status: "in_progress" };
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const dbMock = {
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => [],
}),
}),
}),
};
const res = await request(await createApp(dbMock))
.patch(`/api/issues/${issue.id}`)
.send({ title: "Updated title" });
expect(res.status).toBe(200);
expect(mockLogActivity).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "issue.successful_run_handoff_resolved" }),
);
});
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
const existingPolicy = normalizeIssueExecutionPolicy({
stages: [
@@ -0,0 +1,313 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const assigneeAgentId = "22222222-2222-4222-8222-222222222222";
const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockIssueService = vi.hoisted(() => ({
create: vi.fn(),
createChild: vi.fn(),
getById: vi.fn(),
getByIdentifier: vi.fn(async () => null),
getComment: vi.fn(),
getCommentCursor: vi.fn(),
getRelationSummaries: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
findMentionedAgents: vi.fn(async () => []),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
}),
documentService: () => ({
getIssueDocumentPayload: vi.fn(async () => ({})),
}),
executionWorkspaceService: () => ({
getById: vi.fn(async () => null),
}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
}),
goalService: () => ({
getById: vi.fn(async () => null),
getDefaultCompanyGoal: vi.fn(async () => null),
}),
heartbeatService: () => ({
wakeup: mockWakeup,
reportRunActivity: vi.fn(async () => undefined),
}),
getIssueContinuationSummaryDocument: vi.fn(async () => null),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
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,
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({
getById: vi.fn(async () => null),
listByIds: vi.fn(async () => []),
}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({
listForIssue: vi.fn(async () => []),
}),
}));
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
function makeIssue(input: {
id: string;
title: string;
status?: string;
parentId?: string | null;
assigneeAgentId?: string | null;
}) {
return {
id: input.id,
companyId: "company-1",
identifier: input.id === "child-1" ? "PAP-3701" : "PAP-3700",
title: input.title,
description: null,
status: input.status ?? "todo",
priority: "medium",
parentId: input.parentId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: "local-board",
executionWorkspaceId: null,
labels: [],
labelIds: [],
};
}
function expectClearAssignedStatusValidation(res: request.Response) {
expect([400, 422]).toContain(res.status);
expect(String(res.body?.error ?? res.text)).toMatch(/assign|assignee|status|backlog|todo/i);
}
describe("assigned backlog creation contract", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.getById.mockResolvedValue(makeIssue({
id: "parent-1",
title: "Parent issue",
status: "blocked",
assigneeAgentId,
}));
mockIssueService.create.mockImplementation(async (_companyId: string, data: Record<string, unknown>) =>
makeIssue({
id: "issue-1",
title: String(data.title),
status: String(data.status),
assigneeAgentId: data.assigneeAgentId as string | null | undefined,
}));
mockIssueService.createChild.mockImplementation(async (_parentId: string, data: Record<string, unknown>) => ({
issue: makeIssue({
id: "child-1",
title: String(data.title),
status: String(data.status),
parentId: "parent-1",
assigneeAgentId: data.assigneeAgentId as string | null | undefined,
}),
parentBlockerAdded: Boolean(data.blockParentUntilDone),
}));
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
});
it("does not silently create a top-level assigned issue as backlog when status is omitted", async () => {
const res = await request(await createApp())
.post("/api/companies/company-1/issues")
.send({
title: "Assigned executable work",
assigneeAgentId,
});
if (res.status !== 201) {
expectClearAssignedStatusValidation(res);
expect(mockIssueService.create).not.toHaveBeenCalled();
expect(mockWakeup).not.toHaveBeenCalled();
return;
}
expect(mockIssueService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Assigned executable work",
assigneeAgentId,
status: "todo",
}),
);
expect(res.body).toEqual(expect.objectContaining({
assigneeAgentId,
status: "todo",
}));
expect(mockWakeup).toHaveBeenCalledWith(
assigneeAgentId,
expect.objectContaining({
source: "assignment",
reason: "issue_assigned",
payload: expect.objectContaining({ mutation: "create" }),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.created",
details: expect.objectContaining({
status: "todo",
statusDefaulted: true,
statusDefaultReason: "assigned_omitted_status",
assignmentWakeSkipped: false,
}),
}),
);
});
it("does not let a parent-blocking assigned child become an unwoken backlog leaf by default", async () => {
const res = await request(await createApp())
.post("/api/issues/parent-1/children")
.send({
title: "Assigned child blocker",
assigneeAgentId,
blockParentUntilDone: true,
});
if (res.status !== 201) {
expectClearAssignedStatusValidation(res);
expect(mockIssueService.createChild).not.toHaveBeenCalled();
expect(mockWakeup).not.toHaveBeenCalled();
return;
}
expect(mockIssueService.createChild).toHaveBeenCalledWith(
"parent-1",
expect.objectContaining({
title: "Assigned child blocker",
assigneeAgentId,
blockParentUntilDone: true,
status: "todo",
}),
);
expect(res.body).toEqual(expect.objectContaining({
assigneeAgentId,
parentId: "parent-1",
status: "todo",
}));
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.child_created",
details: expect.objectContaining({
status: "todo",
statusDefaulted: true,
statusDefaultReason: "assigned_omitted_status",
assignmentWakeSkipped: false,
parentBlockerAdded: true,
}),
}),
);
expect(mockWakeup).toHaveBeenCalledWith(
assigneeAgentId,
expect.objectContaining({
source: "assignment",
reason: "issue_assigned",
payload: expect.objectContaining({ mutation: "create" }),
}),
);
});
it("preserves deliberate assigned backlog as parked work without assignment wakeup", async () => {
const res = await request(await createApp())
.post("/api/companies/company-1/issues")
.send({
title: "Parked assigned work",
assigneeAgentId,
status: "backlog",
});
expect(res.status).toBe(201);
expect(mockIssueService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Parked assigned work",
assigneeAgentId,
status: "backlog",
}),
);
expect(res.body).toEqual(expect.objectContaining({
assigneeAgentId,
status: "backlog",
}));
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.created",
entityId: "issue-1",
details: expect.objectContaining({
status: "backlog",
statusDefaulted: false,
statusDefaultReason: "explicit",
assignmentWakeSkipped: true,
assignmentWakeSkipReason: "assigned_backlog",
}),
}),
);
expect(mockWakeup).not.toHaveBeenCalled();
});
});
@@ -76,6 +76,7 @@ describeEmbeddedPostgres("issue blocker attention", () => {
status: string;
parentId?: string | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
originKind?: string | null;
originId?: string | null;
originFingerprint?: string | null;
@@ -90,6 +91,7 @@ describeEmbeddedPostgres("issue blocker attention", () => {
priority: "medium",
parentId: input.parentId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
assigneeUserId: input.assigneeUserId ?? null,
originKind: input.originKind ?? "manual",
originId: input.originId ?? null,
originFingerprint: input.originFingerprint ?? "default",
@@ -147,6 +149,55 @@ describeEmbeddedPostgres("issue blocker attention", () => {
});
});
it("classifies an assigned backlog blocker leaf without a waiting path as attention-needed", async () => {
const { companyId, agentId } = await createCompany("PBB");
const parentId = await insertIssue({ companyId, identifier: "PBB-1", title: "Parent", status: "blocked" });
const blockerId = await insertIssue({
companyId,
identifier: "PBB-2",
title: "Parked assigned blocker",
status: "backlog",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId });
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PBB-2",
});
});
it("treats a human-owned backlog blocker as a covered waiting path", async () => {
const { companyId } = await createCompany("PBU");
const parentId = await insertIssue({ companyId, identifier: "PBU-1", title: "Parent", status: "blocked" });
const blockerId = await insertIssue({
companyId,
identifier: "PBU-2",
title: "Human-owned parked blocker",
status: "backlog",
assigneeUserId: "board-user-1",
});
await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId });
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PBU-2",
});
});
it("keeps mixed blockers attention-required when any path lacks active work", async () => {
const { companyId, agentId } = await createCompany("PBM");
const parentId = await insertIssue({ companyId, identifier: "PBM-1", title: "Parent", status: "blocked" });
@@ -38,7 +38,12 @@ const mockTxInsert = vi.hoisted(() => vi.fn(() => ({ values: mockTxInsertValues
const mockTx = vi.hoisted(() => ({
insert: mockTxInsert,
}));
const mockDbSelectOrderBy = vi.hoisted(() => vi.fn(async () => []));
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({ orderBy: mockDbSelectOrderBy })));
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
}));
const mockFeedbackService = vi.hoisted(() => ({
@@ -236,9 +241,17 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
mockTxInsertValues.mockReset();
mockTxInsert.mockReset();
mockDbSelect.mockReset();
mockDbSelectFrom.mockReset();
mockDbSelectWhere.mockReset();
mockDbSelectOrderBy.mockReset();
mockDb.transaction.mockReset();
mockTxInsertValues.mockResolvedValue(undefined);
mockTxInsert.mockImplementation(() => ({ values: mockTxInsertValues }));
mockDbSelectOrderBy.mockResolvedValue([]);
mockDbSelectWhere.mockImplementation(() => ({ orderBy: mockDbSelectOrderBy }));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx));
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
@@ -545,6 +558,87 @@ describe.sequential("issue comment reopen routes", () => {
));
});
it("passes validated comment presentation fields to trusted board comment writes", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
body: "Paperclip needs a disposition before this issue can continue.",
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
metadata: {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
},
createdAt: new Date(),
updatedAt: new Date(),
});
mockIssueService.findMentionedAgents.mockResolvedValue([]);
const metadata = {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
};
const presentation = { kind: "system_notice", tone: "warning" };
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Paperclip needs a disposition before this issue can continue.",
presentation,
metadata,
});
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Paperclip needs a disposition before this issue can continue.",
{ agentId: undefined, userId: "local-board", runId: null },
{
authorType: "user",
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
metadata,
},
);
});
it("rejects structured comment presentation fields from agent-authenticated writes", async () => {
const app = await installActor(createApp(), agentActor());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Hidden details",
presentation: { kind: "system_notice", tone: "warning" },
metadata: {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "covert_channel_attempt" }] }],
},
});
expect(res.status).toBe(403);
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("rejects invalid comment metadata before writing a comment", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Invalid metadata",
metadata: { version: 1, arbitrary: true },
});
expect(res.status).toBe(400);
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("does not move dependency-blocked issues to todo via POST comments", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("blocked"));
mockIssueService.getDependencyReadiness.mockResolvedValue({
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS,
buildContinuationSummaryMarkdown,
continuationSummaryParksExecutor,
extractContinuationSummaryNextAction,
} from "../services/issue-continuation-summary.js";
describe("issue continuation summaries", () => {
@@ -83,4 +85,31 @@ describe("issue continuation summaries", () => {
expect(body).toContain("Latest run error (adapter_failed): adapter failed");
expect(body).toContain("Inspect the failed run, fix the cause");
});
it("detects continuation summaries that explicitly park executor work for review", () => {
const body = [
"# Continuation Summary",
"",
"## Next Action",
"",
"- Wait for reviewer feedback or approval before continuing executor work.",
].join("\n");
expect(extractContinuationSummaryNextAction(body)).toBe(
"Wait for reviewer feedback or approval before continuing executor work.",
);
expect(continuationSummaryParksExecutor(body)).toBe(true);
});
it("does not park executor work when the next action is still runnable", () => {
const body = [
"# Continuation Summary",
"",
"## Next Action",
"",
"- Re-check run `25145432006`, then move the issue to `in_review` if the final step is green.",
].join("\n");
expect(continuationSummaryParksExecutor(body)).toBe(false);
});
});
@@ -7,6 +7,7 @@ const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
assertCheckoutOwner: vi.fn(),
update: vi.fn(),
createChild: vi.fn(),
addComment: vi.fn(),
findMentionedAgents: vi.fn(),
getRelationSummaries: vi.fn(),
@@ -16,21 +17,33 @@ const mockIssueService = vi.hoisted(() => ({
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
triggerIssueMonitor: vi.fn(async () => ({ outcome: "triggered" as const })),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockIssueThreadInteractionService = vi.hoisted(() => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
listApprovalsForIssue: vi.fn(async () => []),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
}),
accessService: () => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}),
accessService: () => mockAccessService,
agentService: () => ({
getById: vi.fn(async () => null),
}),
@@ -42,6 +55,9 @@ function registerModuleMocks() {
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
environmentService: () => ({
getById: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
@@ -52,7 +68,7 @@ function registerModuleMocks() {
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueApprovalService: () => mockIssueApprovalService,
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
@@ -67,7 +83,8 @@ function registerModuleMocks() {
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
@@ -76,7 +93,22 @@ function registerModuleMocks() {
}));
}
async function createApp() {
type TestActor =
| {
type: "board";
userId: string;
companyIds: string[];
source: "local_implicit";
isInstanceAdmin: boolean;
}
| {
type: "agent";
agentId: string;
companyId: string;
runId: string | null;
};
async function createApp(actor?: TestActor) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
import("../middleware/index.js"),
import("../routes/issues.js"),
@@ -84,7 +116,7 @@ async function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
(req as any).actor = actor ?? {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
@@ -111,6 +143,229 @@ describe("issue execution policy routes", () => {
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([]);
mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockResolvedValue([]);
mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([]);
mockIssueService.createChild.mockResolvedValue({
issue: {
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
companyId: "company-1",
identifier: "PAP-1002",
title: "Child issue",
},
parentBlockerAdded: false,
});
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
});
it("rejects an agent-authored in_review transition without a review path", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "todo",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1003",
title: "Missing review path",
executionPolicy: null,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
const res = await request(await createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "in_review" });
expect(res.status).toBe(422);
expect(res.body.error).toContain("invalid_issue_disposition");
expect(res.body.error).toContain("request_confirmation");
expect(res.body.details).toMatchObject({
code: "invalid_issue_disposition",
missing: "review_path",
});
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("allows an agent-authored in_review transition with a pending confirmation interaction", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "todo",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1004",
title: "Pending confirmation",
executionPolicy: null,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([
{ id: "interaction-1", kind: "request_confirmation", status: "pending" },
]);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "in_review" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.objectContaining({ status: "in_review" }),
);
});
it("allows an agent-authored in_review transition with a typed execution participant", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "todo",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1005",
title: "Execution participant",
executionPolicy: null,
executionState: null,
};
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "review",
participants: [{ type: "agent", agentId: "44444444-4444-4444-8444-444444444444" }],
},
],
})!;
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "in_review", executionPolicy: policy });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.objectContaining({
status: "in_review",
executionState: expect.objectContaining({
status: "pending",
currentParticipant: expect.objectContaining({
type: "agent",
agentId: "44444444-4444-4444-8444-444444444444",
}),
}),
}),
);
});
it("allows an agent-authored in_review transition with a scheduled monitor", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "todo",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1006",
title: "External review monitor",
executionPolicy: null,
executionState: null,
monitorAttemptCount: 0,
monitorNextCheckAt: null,
monitorLastTriggeredAt: null,
monitorNotes: null,
monitorScheduledBy: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({
status: "in_review",
executionPolicy: {
monitor: {
nextCheckAt: "2026-12-01T12:00:00.000Z",
scheduledBy: "assignee",
notes: "Wait for external QA report.",
},
},
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.objectContaining({
status: "in_review",
monitorNextCheckAt: new Date("2026-12-01T12:00:00.000Z"),
}),
);
});
it("allows board-authored in_review repair updates without a review path", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "todo",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1007",
title: "Board repair",
executionPolicy: null,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp())
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "in_review" });
expect(res.status).toBe(200);
expect(mockIssueThreadInteractionService.listForIssue).not.toHaveBeenCalled();
expect(mockIssueApprovalService.listApprovalsForIssue).not.toHaveBeenCalled();
});
it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => {
@@ -162,4 +417,175 @@ describe("issue execution policy routes", () => {
expect(updatePatch.executionState).toBeUndefined();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("triggers a scheduled monitor immediately from the dedicated route", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1001",
title: "Manual monitor trigger",
executionPolicy: normalizeIssueExecutionPolicy({
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "board",
},
}),
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
const res = await request(await createApp())
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/monitor/check-now")
.send({});
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(mockHeartbeatService.triggerIssueMonitor).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.objectContaining({
actorType: "user",
actorId: "local-board",
agentId: null,
}),
);
});
it("lets a board user create a child issue with a scheduled monitor", async () => {
mockIssueService.getById.mockResolvedValue({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1001",
title: "Parent issue",
executionPolicy: null,
executionState: null,
});
const res = await request(await createApp())
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
.send({
title: "Child monitor",
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
executionPolicy: {
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
scheduledBy: "assignee",
},
},
});
expect(res.status).toBe(201);
const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as {
executionPolicy: { monitor: { scheduledBy: string } };
};
expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("board");
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.monitor_scheduled",
details: expect.objectContaining({
scheduledBy: "board",
}),
}),
);
});
it("rejects child monitor scheduling by a non-assignee agent even with task assignment permission", async () => {
mockAccessService.hasPermission.mockResolvedValue(true);
mockIssueService.getById.mockResolvedValue({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1001",
title: "Parent issue",
executionPolicy: null,
executionState: null,
});
const res = await request(await createApp({
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
runId: "run-1",
}))
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
.send({
title: "Child monitor",
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
executionPolicy: {
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
scheduledBy: "board",
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toBe("Only the assignee agent or a board user can manage issue monitors");
expect(mockIssueService.createChild).not.toHaveBeenCalled();
});
it("normalizes spoofed child monitor scheduledBy to the assignee actor", async () => {
mockAccessService.hasPermission.mockResolvedValue(true);
mockIssueService.getById.mockResolvedValue({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1001",
title: "Parent issue",
executionPolicy: null,
executionState: null,
});
const res = await request(await createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
}))
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
.send({
title: "Child monitor",
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
executionPolicy: {
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
scheduledBy: "board",
externalRef: "https://example.test/deploy?token=secret",
},
},
});
expect(res.status).toBe(201);
const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as {
executionPolicy: { monitor: { scheduledBy: string; externalRef: string | null } };
};
expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("assignee");
expect(createPayload.executionPolicy.monitor.externalRef).toBe("[redacted]");
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.monitor_scheduled",
entityId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
details: expect.not.objectContaining({ externalRef: expect.anything() }),
}),
);
});
});
@@ -112,6 +112,26 @@ describe("normalizeIssueExecutionPolicy", () => {
it("throws for invalid input", () => {
expect(() => normalizeIssueExecutionPolicy({ stages: [{ type: "invalid_type" }] })).toThrow();
});
it("keeps monitor-only policies", () => {
const result = normalizeIssueExecutionPolicy({
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
externalRef: "https://example.test/deploy?token=secret",
},
stages: [],
});
expect(result).toMatchObject({
stages: [],
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "assignee",
externalRef: "[redacted]",
},
});
});
});
describe("parseIssueExecutionState", () => {
@@ -1261,4 +1281,169 @@ describe("issue execution policy transitions", () => {
});
});
});
describe("monitor policy", () => {
it("schedules a one-shot monitor on an active agent-owned issue", () => {
const policy = normalizeIssueExecutionPolicy({
stages: [],
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "board",
},
})!;
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: null,
executionState: null,
monitorAttemptCount: 0,
monitorNextCheckAt: null,
monitorLastTriggeredAt: null,
monitorNotes: null,
monitorScheduledBy: null,
},
policy,
previousPolicy: null,
requestedAssigneePatch: {},
actor: { userId: boardUserId },
monitorExplicitlyUpdated: true,
});
expect(result.patch.monitorNextCheckAt).toEqual(new Date("2026-04-11T12:30:00.000Z"));
expect(result.patch.monitorScheduledBy).toBe("board");
expect(result.patch.executionState).toMatchObject({
status: "idle",
monitor: {
status: "scheduled",
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "board",
},
});
});
it("auto-clears a scheduled monitor when the issue moves to done", () => {
const policy = normalizeIssueExecutionPolicy({
stages: [],
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "assignee",
},
})!;
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: {
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: {
status: "scheduled",
nextCheckAt: "2026-04-11T12:30:00.000Z",
lastTriggeredAt: null,
attemptCount: 0,
notes: "Check deployment",
scheduledBy: "assignee",
clearedAt: null,
clearReason: null,
},
},
monitorAttemptCount: 0,
monitorNextCheckAt: new Date("2026-04-11T12:30:00.000Z"),
monitorLastTriggeredAt: null,
monitorNotes: "Check deployment",
monitorScheduledBy: "assignee",
},
policy,
previousPolicy: policy,
requestedStatus: "done",
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
});
expect(result.patch.executionPolicy).toBeNull();
expect(result.patch.monitorNextCheckAt).toBeNull();
expect(result.patch.executionState).toMatchObject({
monitor: {
status: "cleared",
clearReason: "done",
},
});
});
it("rejects explicitly scheduling a monitor on an invalid issue state", () => {
const policy = normalizeIssueExecutionPolicy({
stages: [],
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
},
})!;
expect(() =>
applyIssueExecutionPolicyTransition({
issue: {
status: "blocked",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: null,
executionState: null,
},
policy,
previousPolicy: null,
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
monitorExplicitlyUpdated: true,
}),
).toThrow("Monitor can only be scheduled");
});
it("rejects explicitly re-arming a monitor after max attempts are exhausted", () => {
const policy = normalizeIssueExecutionPolicy({
stages: [],
monitor: {
nextCheckAt: "2099-04-11T12:30:00.000Z",
maxAttempts: 1,
scheduledBy: "assignee",
},
})!;
expect(() =>
applyIssueExecutionPolicyTransition({
issue: {
status: "in_review",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: null,
executionState: null,
monitorAttemptCount: 1,
monitorNextCheckAt: null,
monitorLastTriggeredAt: null,
monitorNotes: null,
monitorScheduledBy: "assignee",
},
policy,
previousPolicy: null,
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
monitorExplicitlyUpdated: true,
}),
).toThrow("Monitor bounds are already exhausted");
});
});
});
@@ -0,0 +1,105 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { companies, createDb, issues } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue identifier route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("issue identifier routes", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-identifier-routes-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterAll(async () => {
await tempDb?.cleanup();
});
function createApp(companyId: string) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "cloud-user-1",
companyIds: [companyId],
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
source: "cloud_tenant",
isInstanceAdmin: true,
};
next();
});
app.use("/api", issueRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
it("resolves alphanumeric Cloud tenant issue identifiers for detail reads and updates", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Cloud tenant",
issuePrefix: "PC1A2",
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
issueNumber: 7,
identifier: "PC1A2-7",
title: "Tenant identifier route",
status: "todo",
priority: "medium",
createdByUserId: "cloud-user-1",
});
const app = createApp(companyId);
const read = await request(app).get("/api/issues/pc1a2-7");
expect(read.status, JSON.stringify(read.body)).toBe(200);
expect(read.body).toMatchObject({
id: issueId,
companyId,
identifier: "PC1A2-7",
});
const updated = await request(app)
.patch("/api/issues/PC1A2-7")
.send({ priority: "high" });
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
expect(updated.body).toMatchObject({
id: issueId,
companyId,
identifier: "PC1A2-7",
priority: "high",
});
const stored = await db
.select({ priority: issues.priority })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(stored?.priority).toBe("high");
});
});
@@ -152,6 +152,73 @@ describe("issue graph liveness classifier", () => {
expect(findings).toEqual([]);
});
it("detects an assigned backlog blocker leaf with no action path", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Parked assigned unblock work",
status: "backlog",
assigneeAgentId: "blocker-agent",
}),
],
relations: blocks,
agents: [
agent(),
manager,
agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }),
],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
issueId: blockedId,
identifier: "PAP-1703",
state: "blocked_by_assigned_backlog_issue",
recoveryIssueId: blockerId,
recommendedOwnerAgentId: "blocker-agent",
dependencyPath: [
expect.objectContaining({ issueId: blockedId }),
expect.objectContaining({ issueId: blockerId, status: "backlog" }),
],
incidentKey: `harness_liveness:${companyId}:${blockedId}:blocked_by_assigned_backlog_issue:${blockerId}`,
});
});
it("does not flag an assigned backlog blocker that has an explicit waiting path", () => {
const backlogBlocker = issue({
id: blockerId,
identifier: "PAP-1704",
title: "Explicitly parked unblock work",
status: "backlog",
assigneeAgentId: "blocker-agent",
});
const baseInput = {
issues: [issue(), backlogBlocker],
relations: blocks,
agents: [
agent(),
manager,
agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }),
],
};
expect(classifyIssueGraphLiveness({
...baseInput,
issues: [issue(), { ...backlogBlocker, assigneeAgentId: null, assigneeUserId: "board-user-1" }],
})).toEqual([]);
expect(classifyIssueGraphLiveness({
...baseInput,
activeRuns: [{ companyId, issueId: blockerId, agentId: "blocker-agent", status: "running" }],
})).toEqual([]);
expect(classifyIssueGraphLiveness({
...baseInput,
openRecoveryIssues: [{ companyId, issueId: blockerId, status: "todo" }],
})).toEqual([]);
});
it("does not flag an unassigned blocker that already has an active execution path", () => {
const findings = classifyIssueGraphLiveness({
issues: [
@@ -0,0 +1,450 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentRuntimeState,
agentWakeupRequests,
agents,
companies,
companySkills,
createDb,
documentRevisions,
documents,
environmentLeases,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueDocuments,
issues,
workspaceRuntimeServices,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { normalizeIssueExecutionPolicy, parseIssueExecutionState } from "../services/issue-execution-policy.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue monitor scheduler tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("issue monitor scheduler", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const seededAgentIds = new Set<string>();
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-monitor-");
db = createDb(tempDb.connectionString);
}, 20_000);
async function waitForHeartbeatIdle(timeoutMs = 3_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const active = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(sql`${heartbeatRuns.status} in ('queued', 'running', 'scheduled_retry')`);
if (active.length === 0) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("Timed out waiting for issue monitor heartbeat runs to settle");
}
async function heartbeatSideEffectFingerprint() {
const [active, events, activity, leases, runtimeServices] = await Promise.all([
db
.select({ count: sql<number>`count(*)` })
.from(heartbeatRuns)
.where(sql`${heartbeatRuns.status} in ('queued', 'running', 'scheduled_retry')`),
db.select({ count: sql<number>`count(*)` }).from(heartbeatRunEvents),
db.select({ count: sql<number>`count(*)` }).from(activityLog),
db.select({ count: sql<number>`count(*)` }).from(environmentLeases),
db.select({ count: sql<number>`count(*)` }).from(workspaceRuntimeServices),
]);
return [
active[0]?.count ?? 0,
events[0]?.count ?? 0,
activity[0]?.count ?? 0,
leases[0]?.count ?? 0,
runtimeServices[0]?.count ?? 0,
].join(":");
}
async function waitForHeartbeatSideEffectsSettled(timeoutMs = 5_000, quietMs = 500) {
const deadline = Date.now() + timeoutMs;
let previous = "";
let stableSince = Date.now();
while (Date.now() < deadline) {
const current = await heartbeatSideEffectFingerprint();
const activeCount = Number(current.split(":")[0] ?? 0);
if (current !== previous || activeCount > 0) {
previous = current;
stableSince = Date.now();
} else if (Date.now() - stableSince >= quietMs) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("Timed out waiting for issue monitor heartbeat side effects to settle");
}
async function cleanupRows() {
await waitForHeartbeatSideEffectsSettled();
await db.delete(heartbeatRunEvents);
await db.delete(issueComments);
await db.delete(documentRevisions);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(activityLog);
await db.delete(environmentLeases);
await db.delete(workspaceRuntimeServices);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(agents);
await db.delete(companySkills);
await db.delete(companies);
}
afterEach(async () => {
seededAgentIds.clear();
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await cleanupRows();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
throw lastError;
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedFixture(input?: {
agentStatus?: "active" | "paused";
issueStatus?: "in_progress" | "in_review";
monitorAttemptCount?: number;
monitor?: Record<string, unknown>;
}) {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const nextCheckAt = new Date("2026-04-11T12:30:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const monitorAttemptCount = input?.monitorAttemptCount ?? 0;
const monitor = {
nextCheckAt: nextCheckAt.toISOString(),
notes: "Check deploy",
scheduledBy: "assignee",
...(input?.monitor ?? {}),
};
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Monitor Bot",
role: "engineer",
status: input?.agentStatus ?? "active",
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: ["-e", ""],
cwd: process.cwd(),
},
runtimeConfig: {
heartbeat: {
enabled: false,
wakeOnDemand: true,
},
},
permissions: {},
});
seededAgentIds.add(agentId);
await db.insert(issues).values({
id: issueId,
companyId,
title: "Watch external deploy",
status: input?.issueStatus ?? "in_progress",
priority: "medium",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
monitor,
},
executionState: {
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: {
status: "scheduled",
nextCheckAt: nextCheckAt.toISOString(),
lastTriggeredAt: null,
attemptCount: monitorAttemptCount,
notes: "Check deploy",
scheduledBy: "assignee",
serviceName: typeof monitor.serviceName === "string" ? monitor.serviceName : null,
externalRef: typeof monitor.externalRef === "string" ? monitor.externalRef : null,
timeoutAt: typeof monitor.timeoutAt === "string" ? monitor.timeoutAt : null,
maxAttempts: typeof monitor.maxAttempts === "number" ? monitor.maxAttempts : null,
recoveryPolicy: typeof monitor.recoveryPolicy === "string" ? monitor.recoveryPolicy : null,
clearedAt: null,
clearReason: null,
},
},
monitorNextCheckAt: nextCheckAt,
monitorAttemptCount,
monitorNotes: "Check deploy",
monitorScheduledBy: "assignee",
});
return { companyId, agentId, issueId, nextCheckAt };
}
it("triggers due issue monitors once and clears the one-shot schedule", async () => {
const { issueId, agentId } = await seedFixture();
const heartbeat = heartbeatService(db);
const tickAt = new Date("2026-04-11T12:31:00.000Z");
const result = await heartbeat.tickTimers(tickAt);
expect(result.enqueued).toBe(1);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
expect(issue.monitorNextCheckAt).toBeNull();
expect(issue.monitorAttemptCount).toBe(1);
expect(issue.monitorLastTriggeredAt?.toISOString()).toBe(tickAt.toISOString());
expect(normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor ?? null).toBeNull();
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
status: "triggered",
lastTriggeredAt: tickAt.toISOString(),
attemptCount: 1,
});
const wakeup = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(wakeup?.reason).toBe("issue_monitor_due");
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId))
.then((rows) => rows.map((row) => row.action));
expect(activity).toContain("issue.monitor_triggered");
});
it("lets the board trigger a scheduled issue monitor immediately", async () => {
const { issueId, agentId, nextCheckAt } = await seedFixture();
const heartbeat = heartbeatService(db);
const triggeredAt = new Date("2026-04-11T12:00:00.000Z");
const result = await heartbeat.triggerIssueMonitor(issueId, {
now: triggeredAt,
actorType: "user",
actorId: "local-board",
});
expect(result.outcome).toBe("triggered");
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
expect(issue.monitorNextCheckAt).toBeNull();
expect(issue.monitorLastTriggeredAt?.toISOString()).toBe(triggeredAt.toISOString());
expect(issue.monitorAttemptCount).toBe(1);
expect(normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor ?? null).toBeNull();
const wakeup = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(wakeup?.reason).toBe("issue_monitor_due");
expect(wakeup?.payload).toMatchObject({
issueId,
nextCheckAt: nextCheckAt.toISOString(),
source: "manual",
});
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId))
.orderBy(activityLog.createdAt);
expect(activity.map((row) => row.action)).toContain("issue.monitor_triggered");
const triggerEvent = activity.find((row) => row.action === "issue.monitor_triggered");
expect(triggerEvent?.actorType).toBe("user");
expect(triggerEvent?.actorId).toBe("local-board");
expect(triggerEvent?.details).toMatchObject({
nextCheckAt: nextCheckAt.toISOString(),
source: "manual",
});
});
it("clears due monitors that cannot be dispatched and records a skip", async () => {
const { issueId } = await seedFixture({ agentStatus: "paused" });
const heartbeat = heartbeatService(db);
const tickAt = new Date("2026-04-11T12:31:00.000Z");
const result = await heartbeat.tickTimers(tickAt);
expect(result.skipped).toBe(1);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
expect(issue.monitorNextCheckAt).toBeNull();
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
status: "cleared",
clearReason: "dispatch_skipped",
});
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId))
.then((rows) => rows.map((row) => row.action));
expect(activity).toContain("issue.monitor_skipped");
});
it("clears exhausted monitors and queues bounded owner recovery instead of another due check", async () => {
const { issueId, agentId } = await seedFixture({
monitorAttemptCount: 1,
monitor: {
maxAttempts: 1,
recoveryPolicy: "wake_owner",
},
});
const heartbeat = heartbeatService(db);
const tickAt = new Date("2026-04-11T12:31:00.000Z");
const result = await heartbeat.tickTimers(tickAt);
expect(result.enqueued).toBe(0);
expect(result.skipped).toBe(1);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
expect(issue.monitorNextCheckAt).toBeNull();
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
status: "cleared",
clearReason: "max_attempts_exhausted",
});
const wakeup = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(wakeup?.reason).toBe("issue_monitor_recovery");
expect(wakeup?.payload).toMatchObject({
issueId,
clearReason: "max_attempts_exhausted",
maxAttempts: 1,
modelProfile: "cheap",
});
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId))
.then((rows) => rows.map((row) => row.action));
expect(activity).toContain("issue.monitor_exhausted");
expect(activity).toContain("issue.monitor_recovery_wake_queued");
expect(activity).not.toContain("issue.monitor_triggered");
});
it("clears timed-out monitors and creates a visible recovery issue when requested", async () => {
const { issueId, companyId } = await seedFixture({
monitor: {
timeoutAt: "2026-04-11T12:00:00.000Z",
recoveryPolicy: "create_recovery_issue",
},
});
const heartbeat = heartbeatService(db);
const tickAt = new Date("2026-04-11T12:31:00.000Z");
const result = await heartbeat.tickTimers(tickAt);
expect(result.enqueued).toBe(0);
expect(result.skipped).toBe(1);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
expect(issue.monitorNextCheckAt).toBeNull();
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
status: "cleared",
clearReason: "timeout_exceeded",
});
const recoveryIssue = await db
.select()
.from(issues)
.where(eq(issues.originId, issueId))
.then((rows) => rows.find((row) => row.companyId === companyId && row.originKind === "stranded_issue_recovery") ?? null);
expect(recoveryIssue).toMatchObject({
parentId: issueId,
priority: "high",
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
expect(["todo", "in_progress"]).toContain(recoveryIssue?.status);
});
it("omits external monitor refs from wake payloads and activity details", async () => {
const { issueId, agentId } = await seedFixture({
monitor: {
serviceName: "Deploy provider",
externalRef: "https://provider.example/deploy/123?token=secret",
},
});
const heartbeat = heartbeatService(db);
const tickAt = new Date("2026-04-11T12:31:00.000Z");
await heartbeat.tickTimers(tickAt);
const wakeup = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(JSON.stringify(wakeup?.payload)).not.toContain("provider.example");
expect(wakeup?.payload).not.toHaveProperty("externalRef");
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
expect(JSON.stringify(activity.map((row) => row.details))).not.toContain("provider.example");
expect(activity.find((row) => row.action === "issue.monitor_triggered")?.details).not.toHaveProperty("externalRef");
});
});
@@ -0,0 +1,518 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
agentWakeupRequests,
companies,
createDb,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueRelations,
issueTreeHolds,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres scheduled retry route tests on this host: ${
embeddedPostgresSupport.reason ?? "unsupported environment"
}`,
);
}
describeEmbeddedPostgres("issue scheduled retry routes", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-scheduled-retry-routes-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueTreeHolds);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
function createApp(actor: Express.Request["actor"]) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", issueRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
function boardActor(companyId: string): Express.Request["actor"] {
return {
type: "board",
userId: "board-user",
companyIds: [companyId],
memberships: [{ companyId, membershipRole: "admin", status: "active" }],
isInstanceAdmin: false,
source: "session",
};
}
function agentActor(companyId: string, agentId: string): Express.Request["actor"] {
return {
type: "agent",
agentId,
companyId,
runId: randomUUID(),
source: "agent_jwt",
};
}
async function seedIssueWithRetry(input: {
agentStatus?: "active" | "paused";
retryStatus?: "scheduled_retry" | "queued" | "running";
issueStatus?: "in_progress" | "todo" | "done" | "cancelled";
} = {}) {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const sourceRunId = randomUUID();
const retryRunId = randomUUID();
const wakeupRequestId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const now = new Date("2026-05-06T18:00:00.000Z");
const scheduledRetryAt = new Date("2026-05-06T19:00:00.000Z");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: input.agentStatus ?? "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
wakeOnDemand: true,
maxConcurrentRuns: 1,
},
},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: sourceRunId,
companyId,
agentId,
invocationSource: "assignment",
triggerDetail: "system",
status: "failed",
error: "transient upstream error",
errorCode: "adapter_failed",
finishedAt: now,
contextSnapshot: {
issueId,
wakeReason: "issue_assigned",
},
updatedAt: now,
createdAt: now,
});
await db.insert(agentWakeupRequests).values({
id: wakeupRequestId,
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "bounded_transient_heartbeat_retry",
payload: {
issueId,
retryOfRunId: sourceRunId,
scheduledRetryAt: scheduledRetryAt.toISOString(),
},
status: "queued",
});
await db.insert(heartbeatRuns).values({
id: retryRunId,
companyId,
agentId,
invocationSource: "automation",
triggerDetail: "system",
status: input.retryStatus ?? "scheduled_retry",
wakeupRequestId,
retryOfRunId: sourceRunId,
scheduledRetryAt,
scheduledRetryAttempt: 2,
scheduledRetryReason: "transient_failure",
contextSnapshot: {
issueId,
wakeReason: "bounded_transient_heartbeat_retry",
retryOfRunId: sourceRunId,
scheduledRetryAt: scheduledRetryAt.toISOString(),
scheduledRetryAttempt: 2,
retryReason: "transient_failure",
},
updatedAt: now,
createdAt: now,
});
await db
.update(agentWakeupRequests)
.set({ runId: retryRunId })
.where(eq(agentWakeupRequests.id, wakeupRequestId));
await db.insert(issues).values({
id: issueId,
companyId,
title: "Retryable issue",
status: input.issueStatus ?? "in_progress",
priority: "medium",
assigneeAgentId: agentId,
executionRunId: retryRunId,
executionAgentNameKey: "codexcoder",
executionLockedAt: now,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
return { companyId, agentId, issueId, sourceRunId, retryRunId, scheduledRetryAt };
}
it("surfaces the current scheduled retry in the issue read model", async () => {
const { companyId, issueId, agentId, sourceRunId, retryRunId, scheduledRetryAt } = await seedIssueWithRetry();
const res = await request(createApp(boardActor(companyId))).get(`/api/issues/${issueId}`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.scheduledRetry).toMatchObject({
runId: retryRunId,
status: "scheduled_retry",
agentId,
agentName: "CodexCoder",
retryOfRunId: sourceRunId,
scheduledRetryAttempt: 2,
scheduledRetryReason: "transient_failure",
});
expect(res.body.scheduledRetry.scheduledRetryAt).toBe(scheduledRetryAt.toISOString());
});
it("promotes the existing scheduled retry and treats duplicate clicks as idempotent", async () => {
const { companyId, issueId, retryRunId } = await seedIssueWithRetry();
const app = createApp(boardActor(companyId));
const first = await request(app).post(`/api/issues/${issueId}/scheduled-retry/retry-now`).send({});
expect(first.status, JSON.stringify(first.body)).toBe(200);
expect(first.body).toMatchObject({
outcome: "promoted",
scheduledRetry: {
runId: retryRunId,
status: "queued",
},
});
const second = await request(app).post(`/api/issues/${issueId}/scheduled-retry/retry-now`).send({});
expect(second.status, JSON.stringify(second.body)).toBe(200);
expect(second.body).toMatchObject({
outcome: "already_promoted",
scheduledRetry: {
runId: retryRunId,
status: "queued",
},
});
const retryRuns = await db
.select({ id: heartbeatRuns.id, status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.retryOfRunId, first.body.scheduledRetry.retryOfRunId), eq(heartbeatRuns.companyId, companyId)));
expect(retryRuns).toHaveLength(1);
expect(retryRuns[0]).toMatchObject({ id: retryRunId, status: "queued" });
});
it("returns a clear no-op response when there is no scheduled retry", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "NONE",
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "No retry",
status: "todo",
priority: "medium",
issueNumber: 1,
identifier: "NONE-1",
});
const res = await request(createApp(boardActor(companyId)))
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
outcome: "no_scheduled_retry",
scheduledRetry: null,
});
});
it("reports already-promoted retries without creating another run", async () => {
const { companyId, issueId, retryRunId } = await seedIssueWithRetry({ retryStatus: "queued" });
const res = await request(createApp(boardActor(companyId)))
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
outcome: "already_promoted",
scheduledRetry: {
runId: retryRunId,
status: "queued",
},
});
});
it("uses normal promotion gates and records gate-suppressed retries", async () => {
const { companyId, issueId, retryRunId } = await seedIssueWithRetry({ agentStatus: "paused" });
const res = await request(createApp(boardActor(companyId)))
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
outcome: "gate_suppressed",
scheduledRetry: {
runId: retryRunId,
status: "cancelled",
errorCode: "agent_not_invokable",
},
});
const [run] = await db
.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, retryRunId));
expect(run).toEqual({ status: "cancelled", errorCode: "agent_not_invokable" });
const [activity] = await db
.select({ action: activityLog.action, entityId: activityLog.entityId, runId: activityLog.runId })
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
expect(activity).toEqual({
action: "issue.scheduled_retry_retry_now",
entityId: issueId,
runId: retryRunId,
});
});
it("requires board access for retry-now", async () => {
const { companyId, agentId, issueId } = await seedIssueWithRetry();
const res = await request(createApp(agentActor(companyId, agentId)))
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
.send({});
expect(res.status).toBe(403);
});
it("enforces company scoping for retry-now", async () => {
const { issueId } = await seedIssueWithRetry();
const res = await request(createApp(boardActor(randomUUID())))
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
.send({});
expect(res.status).toBe(403);
});
it("suppresses retry-now when the issue is under a budget hard-stop", async () => {
const { companyId, agentId, issueId, retryRunId } = await seedIssueWithRetry();
await db
.update(agents)
.set({ status: "paused", pauseReason: "budget" })
.where(eq(agents.id, agentId));
const res = await request(createApp(boardActor(companyId)))
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
outcome: "gate_suppressed",
scheduledRetry: {
runId: retryRunId,
status: "cancelled",
errorCode: "budget_blocked",
},
});
});
it("suppresses retry-now when the issue is waiting on another review participant", async () => {
const { companyId, agentId, issueId, retryRunId } = await seedIssueWithRetry({ issueStatus: "in_progress" });
const reviewerAgentId = randomUUID();
await db.insert(agents).values({
id: reviewerAgentId,
companyId,
name: "ReviewerAgent",
role: "qa",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
wakeOnDemand: true,
maxConcurrentRuns: 1,
},
},
permissions: {},
});
await db
.update(issues)
.set({
status: "in_review",
executionState: {
status: "pending",
currentStageId: randomUUID(),
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: reviewerAgentId, userId: null },
returnAssignee: { type: "agent", agentId, userId: null },
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
})
.where(eq(issues.id, issueId));
const res = await request(createApp(boardActor(companyId)))
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
outcome: "gate_suppressed",
scheduledRetry: {
runId: retryRunId,
status: "cancelled",
errorCode: "issue_review_participant_changed",
},
});
});
it("suppresses retry-now when the issue is under an active subtree pause hold", async () => {
const { companyId, issueId, retryRunId } = await seedIssueWithRetry();
await db.insert(issueTreeHolds).values({
companyId,
rootIssueId: issueId,
mode: "pause",
status: "active",
reason: "manual pause for review",
releasePolicy: { strategy: "manual" },
});
const res = await request(createApp(boardActor(companyId)))
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
outcome: "gate_suppressed",
scheduledRetry: {
runId: retryRunId,
status: "cancelled",
errorCode: "issue_paused",
},
});
});
it("suppresses retry-now when unresolved blockers remain", async () => {
const { companyId, issueId, retryRunId } = await seedIssueWithRetry();
const blockerId = randomUUID();
await db.insert(issues).values({
id: blockerId,
companyId,
title: "Blocking task",
status: "todo",
priority: "medium",
issueNumber: 2,
identifier: "BLOCK-2",
});
await db.insert(issueRelations).values({
id: randomUUID(),
companyId,
issueId: blockerId,
relatedIssueId: issueId,
type: "blocks",
});
await db
.update(issues)
.set({ status: "blocked" })
.where(eq(issues.id, issueId));
const res = await request(createApp(boardActor(companyId)))
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
outcome: "gate_suppressed",
scheduledRetry: {
runId: retryRunId,
status: "cancelled",
errorCode: "issue_dependencies_blocked",
},
});
});
it("suppresses retry-now when the issue already reached a terminal status", async () => {
const { companyId, issueId, retryRunId } = await seedIssueWithRetry({ issueStatus: "done" });
const res = await request(createApp(boardActor(companyId)))
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
outcome: "gate_suppressed",
scheduledRetry: {
runId: retryRunId,
status: "cancelled",
errorCode: "issue_terminal_status",
},
});
});
});
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
@@ -110,6 +111,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
{
clientKey: "root",
title: "Create the root follow-up",
workMode: "planning",
assigneeAgentId,
},
{
@@ -153,6 +155,19 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
status: "todo",
}),
]);
const createdIssueRows = await db
.select({
title: issues.title,
workMode: issues.workMode,
})
.from(issues)
.where(eq(issues.companyId, companyId));
expect(createdIssueRows).toEqual(
expect.arrayContaining([
expect.objectContaining({ title: "Create the root follow-up", workMode: "planning" }),
expect.objectContaining({ title: "Create the nested follow-up", workMode: "standard" }),
]),
);
const children = await issuesSvc.list(companyId, { parentId: issueId });
expect(children).toHaveLength(1);
@@ -13,6 +13,7 @@ const mockIssueService = vi.hoisted(() => ({
getComment: vi.fn(),
listBlockerAttention: vi.fn(),
listProductivityReviews: vi.fn(),
getCurrentScheduledRetry: vi.fn(),
listAttachments: vi.fn(),
}));
@@ -91,6 +92,11 @@ const mockWorkProductService = vi.hoisted(() => ({
const mockEnvironmentService = vi.hoisted(() => ({}));
const mockDb = vi.hoisted(() => ({
select: vi.fn(),
execute: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
@@ -130,7 +136,7 @@ function createApp() {
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(mockDb as any, {} as any));
app.use(errorHandler);
return app;
}
@@ -142,6 +148,7 @@ const legacyProjectLinkedIssue = {
title: "Legacy onboarding task",
description: "Seed the first CEO task",
status: "todo",
workMode: "planning",
priority: "medium",
projectId: "22222222-2222-4222-8222-222222222222",
goalId: null,
@@ -182,10 +189,19 @@ describe.sequential("issue goal context routes", () => {
mockIssueService.getComment.mockResolvedValue(null);
mockIssueService.listBlockerAttention.mockResolvedValue(new Map());
mockIssueService.listProductivityReviews.mockResolvedValue(new Map());
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
mockIssueService.listAttachments.mockResolvedValue([]);
mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({});
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
mockDb.select.mockReturnValue({
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn(async () => []),
})),
})),
});
mockDb.execute.mockResolvedValue([]);
mockProjectService.getById.mockResolvedValue({
id: legacyProjectLinkedIssue.projectId,
companyId: "company-1",
@@ -251,6 +267,7 @@ describe.sequential("issue goal context routes", () => {
expect(res.status).toBe(200);
expect(res.body.issue.goalId).toBe(projectGoal.id);
expect(res.body.issue.workMode).toBe("planning");
expect(res.body.goal).toEqual(
expect.objectContaining({
id: projectGoal.id,
+488 -5
View File
@@ -7,6 +7,7 @@ import {
agents,
companies,
createDb,
environments,
executionWorkspaces,
goals,
heartbeatRuns,
@@ -459,14 +460,14 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(result.map((issue) => issue.id)).toEqual([grandchildId]);
});
it("accepts issue identifiers through getById", async () => {
it("accepts issue identifiers with alphanumeric prefixes through getById", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
issuePrefix: "PC1A2",
requireBoardApprovalForNewAgents: false,
});
@@ -474,19 +475,19 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
id: issueId,
companyId,
issueNumber: 1064,
identifier: "PAP-1064",
identifier: "PC1A2-1064",
title: "Feedback votes error",
status: "todo",
priority: "medium",
createdByUserId: "user-1",
});
const issue = await svc.getById("PAP-1064");
const issue = await svc.getById("pc1a2-1064");
expect(issue).toEqual(
expect.objectContaining({
id: issueId,
identifier: "PAP-1064",
identifier: "PC1A2-1064",
}),
);
});
@@ -656,6 +657,143 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort());
});
it("hides plugin operation issues from default lists and inbox-style filters while preserving explicit retrieval", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const normalIssueId = randomUUID();
const pluginVisibleIssueId = randomUUID();
const operationIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Plugin Runner",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Plugin operations",
status: "in_progress",
});
await db.insert(issues).values([
{
id: normalIssueId,
companyId,
title: "Normal issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
},
{
id: pluginVisibleIssueId,
companyId,
title: "Plugin-visible issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclip.missions:feature",
},
{
id: operationIssueId,
companyId,
projectId,
title: "Plugin operation issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclip.missions:operation",
originId: "mission-alpha:operation-1",
},
]);
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
expect(defaultIssueIds).toContain(normalIssueId);
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
expect(defaultIssueIds).not.toContain(operationIssueId);
const inboxIssueIds = (await svc.list(companyId, {
assigneeAgentId: agentId,
status: "todo,in_progress,blocked",
includeRoutineExecutions: true,
})).map((issue) => issue.id);
expect(inboxIssueIds).toContain(normalIssueId);
expect(inboxIssueIds).not.toContain(operationIssueId);
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
expect(projectIssueIds).toContain(operationIssueId);
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
expect(advancedIssueIds).toContain(operationIssueId);
});
it("excludes plugin operation issues from unread inbox counts", async () => {
const companyId = randomUUID();
const userId = "board-user";
const otherUserId = "other-user";
const normalIssueId = randomUUID();
const operationIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: normalIssueId,
companyId,
title: "Normal touched issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
},
{
id: operationIssueId,
companyId,
title: "Plugin operation touched issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
originKind: "plugin:paperclip.missions:operation",
},
]);
await db.insert(issueComments).values([
{
companyId,
issueId: normalIssueId,
authorUserId: otherUserId,
body: "Unread normal update.",
},
{
companyId,
issueId: operationIssueId,
authorUserId: otherUserId,
body: "Unread operation update.",
},
]);
await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1);
});
it("hides archived inbox issues until new external activity arrives", async () => {
const companyId = randomUUID();
const userId = "user-1";
@@ -1184,6 +1322,351 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
});
});
it("captures the assignee default environment when neither issue nor project specifies one", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const assigneeEnvironmentId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(environments).values([
{
id: assigneeEnvironmentId,
companyId,
name: "QA E2B",
driver: "sandbox",
status: "active",
config: { provider: "e2b" },
},
]);
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "QA E2B Codex",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: assigneeEnvironmentId,
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
defaultProjectWorkspaceId: projectWorkspaceId,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
const issue = await svc.create(companyId, {
projectId,
assigneeAgentId,
title: "Environment matrix: e2b / codex_local",
status: "todo",
priority: "medium",
});
expect(issue.executionWorkspaceSettings).toEqual({
mode: "shared_workspace",
environmentId: assigneeEnvironmentId,
});
});
it("does not promote the assignee default environment when the project policy already specifies one", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const projectEnvironmentId = randomUUID();
const assigneeEnvironmentId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(environments).values([
{
id: projectEnvironmentId,
companyId,
name: "QA SSH",
driver: "ssh",
status: "active",
config: {},
},
{
id: assigneeEnvironmentId,
companyId,
name: "QA E2B",
driver: "sandbox",
status: "active",
config: { provider: "e2b" },
},
]);
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "QA E2B Codex",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: assigneeEnvironmentId,
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
defaultProjectWorkspaceId: projectWorkspaceId,
environmentId: projectEnvironmentId,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
const issue = await svc.create(companyId, {
projectId,
assigneeAgentId,
title: "Environment matrix: e2b / codex_local",
status: "todo",
priority: "medium",
});
// Project policy's environmentId must win over the assignee's default;
// executionWorkspaceSettings should not bake in an environmentId in this case
// so resolveExecutionWorkspaceEnvironmentId can fall through to the project
// policy's value at run time.
expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace" });
});
it("captures the new assignee's default environment on reassignment", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const firstEnvironmentId = randomUUID();
const secondEnvironmentId = randomUUID();
const firstAgentId = randomUUID();
const secondAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(environments).values([
{
id: firstEnvironmentId,
companyId,
name: "QA SSH",
driver: "ssh",
status: "active",
config: {},
},
{
id: secondEnvironmentId,
companyId,
name: "QA E2B",
driver: "sandbox",
status: "active",
config: { provider: "e2b" },
},
]);
await db.insert(agents).values([
{
id: firstAgentId,
companyId,
name: "QA SSH Codex",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: firstEnvironmentId,
permissions: {},
},
{
id: secondAgentId,
companyId,
name: "QA E2B Codex",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: secondEnvironmentId,
permissions: {},
},
]);
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
defaultProjectWorkspaceId: projectWorkspaceId,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
const created = await svc.create(companyId, {
projectId,
assigneeAgentId: firstAgentId,
title: "Environment matrix: ssh / codex_local",
status: "todo",
priority: "medium",
});
expect(created.executionWorkspaceSettings).toMatchObject({
environmentId: firstEnvironmentId,
});
const reassigned = await svc.update(created.id, {
assigneeAgentId: secondAgentId,
});
expect(reassigned).not.toBeNull();
expect(reassigned!.executionWorkspaceSettings).toMatchObject({
environmentId: secondEnvironmentId,
});
});
it("preserves an operator-set environmentId across reassignment", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const firstEnvironmentId = randomUUID();
const secondEnvironmentId = randomUUID();
const operatorEnvironmentId = randomUUID();
const firstAgentId = randomUUID();
const secondAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(environments).values([
{ id: firstEnvironmentId, companyId, name: "Env 1", driver: "ssh", status: "active", config: {} },
{ id: secondEnvironmentId, companyId, name: "Env 2", driver: "sandbox", status: "active", config: { provider: "e2b" } },
{ id: operatorEnvironmentId, companyId, name: "Operator pick", driver: "ssh", status: "active", config: {} },
]);
await db.insert(agents).values([
{
id: firstAgentId, companyId, name: "First agent", role: "engineer", status: "active",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
defaultEnvironmentId: firstEnvironmentId, permissions: {},
},
{
id: secondAgentId, companyId, name: "Second agent", role: "engineer", status: "active",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
defaultEnvironmentId: secondEnvironmentId, permissions: {},
},
]);
await db.insert(projects).values({
id: projectId, companyId, name: "Workspace project", status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
defaultProjectWorkspaceId: projectWorkspaceId,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId, companyId, projectId, name: "Primary workspace", isPrimary: true,
});
const created = await svc.create(companyId, {
projectId,
assigneeAgentId: firstAgentId,
title: "Operator overrides env then reassigns",
status: "todo",
priority: "medium",
});
// Operator explicitly overrides the environmentId in a separate update.
const overridden = await svc.update(created.id, {
executionWorkspaceSettings: {
mode: "shared_workspace",
environmentId: operatorEnvironmentId,
},
});
expect(overridden!.executionWorkspaceSettings).toMatchObject({
environmentId: operatorEnvironmentId,
});
// A subsequent reassignment-only update must NOT overwrite the operator's
// explicit choice with the new assignee's default.
const reassigned = await svc.update(created.id, {
assigneeAgentId: secondAgentId,
});
expect(reassigned!.executionWorkspaceSettings).toMatchObject({
environmentId: operatorEnvironmentId,
});
});
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
+179 -2
View File
@@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
companies,
createDb,
@@ -25,9 +25,11 @@ import {
validatePluginRuntimeExecute,
validatePluginRuntimeQuery,
} from "../services/plugin-database.js";
import { pluginLoader } from "../services/plugin-loader.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const multiMigrationPluginKey = "paperclip.dbfixture";
if (!embeddedPostgresSupport.supported) {
console.warn(
@@ -93,7 +95,7 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
}, 20_000);
afterEach(async () => {
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape"]) {
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey]) {
const namespace = derivePluginDatabaseNamespace(pluginKey);
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
}
@@ -120,6 +122,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
return packageRoot;
}
async function createInstallablePluginPackage(
pluginManifest: PaperclipPluginManifestV1,
migrationSql: string,
) {
const packageRoot = await createPluginPackage(pluginManifest, migrationSql);
await writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({
name: pluginManifest.id,
version: pluginManifest.version,
type: "module",
paperclipPlugin: { manifest: "./manifest.js" },
}),
"utf8",
);
await writeFile(
path.join(packageRoot, "manifest.js"),
`export default ${JSON.stringify(pluginManifest, null, 2)};\n`,
"utf8",
);
await mkdir(path.join(packageRoot, "dist"), { recursive: true });
await writeFile(path.join(packageRoot, "dist", "worker.js"), "export {};\n", "utf8");
return packageRoot;
}
async function installPluginRecord(manifest: PaperclipPluginManifestV1) {
const pluginId = randomUUID();
await db.insert(plugins).values({
@@ -158,6 +185,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
};
}
it("applies multi-file plugin migrations through the production validator", async () => {
const pluginManifest = manifest(multiMigrationPluginKey);
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
const packageRoot = await createPluginPackage(
pluginManifest,
`CREATE TABLE ${namespace}.source_rows (id uuid PRIMARY KEY, label text NOT NULL);`,
);
await writeFile(
path.join(packageRoot, pluginManifest.database!.migrationsDir, "002_derived.sql"),
`CREATE TABLE ${namespace}.derived_rows (
id uuid PRIMARY KEY,
source_id uuid NOT NULL REFERENCES ${namespace}.source_rows(id)
);`,
"utf8",
);
const pluginId = await installPluginRecord(pluginManifest);
await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot);
const migrations = await db
.select()
.from(pluginMigrations)
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
expect(migrations).toHaveLength(2);
});
it("applies migrations once and allows whitelisted core joins at runtime", async () => {
const pluginManifest = manifest();
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
@@ -246,6 +298,131 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
expect(migration?.status).toBe("failed");
});
it("rolls back plugin install when migration validation fails", async () => {
const pluginManifest = manifest("paperclip.escape");
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
const packageRoot = await createInstallablePluginPackage(
pluginManifest,
"CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);",
);
const loader = pluginLoader(db, {
enableLocalFilesystem: false,
enableNpmDiscovery: false,
});
await expect(loader.installPlugin({ localPath: packageRoot }))
.rejects.toThrow(/public\.plugin_escape|public/i);
const installedPlugins = await db
.select()
.from(plugins)
.where(eq(plugins.pluginKey, pluginManifest.id));
const namespaces = await db
.select()
.from(pluginDatabaseNamespaces)
.where(eq(pluginDatabaseNamespaces.pluginKey, pluginManifest.id));
const migrations = await db
.select()
.from(pluginMigrations)
.where(eq(pluginMigrations.pluginKey, pluginManifest.id));
const schemaRows = Array.from(
await db.execute(
sql<{ schema_name: string }>`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ${namespace}`,
) as Iterable<{ schema_name: string }>,
);
expect(installedPlugins).toHaveLength(0);
expect(namespaces).toHaveLength(0);
expect(migrations).toHaveLength(0);
expect(schemaRows).toHaveLength(0);
});
it("refreshes persisted manifests from disk before activation", async () => {
const staleManifest = manifest("paperclip.refresh");
const refreshedManifest: PaperclipPluginManifestV1 = {
...staleManifest,
database: {
...staleManifest.database!,
coreReadTables: ["companies"],
},
};
const namespace = derivePluginDatabaseNamespace(refreshedManifest.id);
const packageRoot = await createInstallablePluginPackage(
refreshedManifest,
`
CREATE TABLE ${namespace}.company_refs (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id)
);
`,
);
const pluginId = await installPluginRecord(staleManifest);
await db
.update(plugins)
.set({
packagePath: packageRoot,
status: "ready",
})
.where(eq(plugins.id, pluginId));
const workerManager = {
startWorker: vi.fn().mockResolvedValue(undefined),
stopAll: vi.fn().mockResolvedValue(undefined),
};
const loader = pluginLoader(db, {
enableLocalFilesystem: false,
enableNpmDiscovery: false,
}, {
workerManager,
eventBus: {
forPlugin: vi.fn(() => ({})),
subscriptionCount: vi.fn(() => 0),
},
jobScheduler: {
registerPlugin: vi.fn().mockResolvedValue(undefined),
stop: vi.fn(),
},
jobStore: {
syncJobDeclarations: vi.fn().mockResolvedValue(undefined),
},
toolDispatcher: {
registerPluginTools: vi.fn(),
},
lifecycleManager: {
markError: vi.fn().mockResolvedValue(undefined),
},
buildHostHandlers: vi.fn(() => ({})),
instanceInfo: {
instanceId: "test-instance",
hostVersion: "1.0.0",
deploymentMode: "authenticated",
deploymentExposure: "public",
},
} as never);
const result = await loader.loadSingle(pluginId);
expect(result.success).toBe(true);
expect(workerManager.startWorker).toHaveBeenCalledWith(
pluginId,
expect.objectContaining({
databaseNamespace: namespace,
env: {
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
},
manifest: expect.objectContaining({
database: expect.objectContaining({ coreReadTables: ["companies"] }),
}),
}),
);
const [plugin] = await db
.select()
.from(plugins)
.where(eq(plugins.id, pluginId));
expect(plugin?.manifestJson.database?.coreReadTables).toEqual(["companies"]);
});
it("rejects checksum changes for already applied migrations", async () => {
const pluginManifest = manifest();
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
@@ -0,0 +1,274 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
import {
assertConfiguredLocalFolder,
assertWritableConfiguredLocalFolder,
inspectPluginLocalFolder,
listPluginLocalFolderEntries,
preparePluginLocalFolder,
readPluginLocalFolderText,
resolvePluginLocalFolderPath,
deletePluginLocalFolderFile,
writePluginLocalFolderTextAtomic,
} from "../services/plugin-local-folders.js";
describe("plugin local folders", () => {
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
tempRoots.length = 0;
});
async function makeRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-folder-"));
tempRoots.push(root);
return root;
}
it("reports a healthy generic folder when required paths exist", async () => {
const root = await makeRoot();
await fs.mkdir(path.join(root, "sources"));
await fs.writeFile(path.join(root, "schema.md"), "schema", "utf8");
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
access: "readWrite",
requiredDirectories: ["sources"],
requiredFiles: ["schema.md"],
},
});
expect(status.healthy).toBe(true);
expect(status.problems).toEqual([]);
expect(status.requiredDirectories).toEqual(["sources"]);
expect(status.requiredFiles).toEqual(["schema.md"]);
});
it("reports missing required folders and files without using product-specific branches", async () => {
const root = await makeRoot();
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
requiredDirectories: ["sources"],
requiredFiles: ["schema.md"],
},
});
expect(status.healthy).toBe(false);
expect(status.missingDirectories).toEqual(["sources"]);
expect(status.missingFiles).toEqual(["schema.md"]);
expect(status.problems.map((item) => item.code)).toEqual(
expect.arrayContaining(["missing_directory", "missing_file"]),
);
});
it("reports all required paths as missing when the configured root does not exist", async () => {
const root = await makeRoot();
const missingRoot = path.join(root, "missing-root");
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: missingRoot,
requiredDirectories: ["sources"],
requiredFiles: ["schema.md"],
},
});
expect(status.healthy).toBe(false);
expect(status.configured).toBe(true);
expect(status.readable).toBe(false);
expect(status.missingDirectories).toEqual(["sources"]);
expect(status.missingFiles).toEqual(["schema.md"]);
expect(status.problems.map((item) => item.code)).toContain("missing");
});
it("uses manifest declaration access and required paths over stored or caller overrides", async () => {
const root = await makeRoot();
await fs.mkdir(path.join(root, "manifest-dir"));
await fs.writeFile(path.join(root, "manifest.md"), "schema", "utf8");
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
declaration: {
folderKey: "content-root",
displayName: "Content root",
access: "read",
requiredDirectories: ["manifest-dir"],
requiredFiles: ["manifest.md"],
},
storedConfig: {
path: root,
access: "readWrite",
requiredDirectories: ["stored-dir"],
requiredFiles: ["stored.md"],
},
overrideConfig: {
access: "readWrite",
requiredDirectories: ["override-dir"],
requiredFiles: ["override.md"],
},
});
expect(status.access).toBe("read");
expect(status.writable).toBe(false);
expect(status.requiredDirectories).toEqual(["manifest-dir"]);
expect(status.requiredFiles).toEqual(["manifest.md"]);
expect(status.healthy).toBe(true);
});
it("prepares required directories for a read-write folder without creating required files", async () => {
const root = await makeRoot();
await preparePluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
access: "readWrite",
requiredDirectories: ["sources", "wiki/concepts"],
requiredFiles: ["schema.md"],
},
});
await expect(fs.stat(path.join(root, "sources"))).resolves.toMatchObject({});
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
await expect(fs.stat(path.join(root, "schema.md"))).rejects.toMatchObject({ code: "ENOENT" });
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
access: "readWrite",
requiredDirectories: ["sources", "wiki/concepts"],
requiredFiles: ["schema.md"],
},
});
expect(status.missingDirectories).toEqual([]);
expect(status.missingFiles).toEqual(["schema.md"]);
});
it("allows write access to repair folders that are only missing required paths", async () => {
const root = await makeRoot();
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
access: "readWrite",
requiredFiles: ["schema.md"],
},
});
expect(status.healthy).toBe(false);
expect(() => assertConfiguredLocalFolder(status)).toThrow("Local folder is not healthy");
expect(() => assertWritableConfiguredLocalFolder(status)).not.toThrow();
await writePluginLocalFolderTextAtomic(root, "schema.md", "schema");
const repaired = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
access: "readWrite",
requiredFiles: ["schema.md"],
},
});
expect(repaired.healthy).toBe(true);
});
it("rejects traversal outside the configured folder", async () => {
const root = await makeRoot();
await expect(resolvePluginLocalFolderPath(root, "../outside.txt")).rejects.toMatchObject({
status: 403,
});
});
it("detects required symlinks that escape the configured folder", async () => {
const root = await makeRoot();
const outside = await makeRoot();
await fs.writeFile(path.join(outside, "secret.txt"), "nope", "utf8");
await fs.symlink(path.join(outside, "secret.txt"), path.join(root, "linked.txt"));
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
requiredFiles: ["linked.txt"],
},
});
expect(status.healthy).toBe(false);
expect(status.problems.some((item) => item.code === "symlink_escape")).toBe(true);
});
it("writes files atomically under the root and can read them back", async () => {
const root = await makeRoot();
await fs.mkdir(path.join(root, "nested"));
await writePluginLocalFolderTextAtomic(root, "nested/page.md", "hello");
await writePluginLocalFolderTextAtomic(root, "nested/page.md", "updated");
await expect(readPluginLocalFolderText(root, "nested/page.md")).resolves.toBe("updated");
const leftovers = await fs.readdir(path.join(root, "nested"));
expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]);
});
it("returns the real folder key after deleting a file", async () => {
const root = await makeRoot();
await fs.writeFile(path.join(root, "stale.md"), "delete me", "utf8");
const status = await deletePluginLocalFolderFile(root, "stale.md", "content-root");
expect(status.folderKey).toBe("content-root");
await expect(fs.stat(path.join(root, "stale.md"))).rejects.toMatchObject({ code: "ENOENT" });
});
it("lists nested local folder entries without following symlink escapes", async () => {
const root = await makeRoot();
const outside = await makeRoot();
await fs.mkdir(path.join(root, "wiki/concepts"), { recursive: true });
await fs.writeFile(path.join(root, "wiki/concepts/live.md"), "# Live\n", "utf8");
await fs.writeFile(path.join(outside, "secret.md"), "# Secret\n", "utf8");
await fs.symlink(outside, path.join(root, "wiki/outside"));
const listing = await listPluginLocalFolderEntries(root, {
relativePath: "wiki",
recursive: true,
maxEntries: 20,
});
expect(listing.entries.map((entry) => entry.path)).toContain("wiki/concepts/live.md");
expect(listing.entries.map((entry) => entry.path)).not.toContain("wiki/outside/secret.md");
expect(listing.truncated).toBe(false);
});
it("revalidates temp-file containment before writing atomic contents", async () => {
const root = await makeRoot();
const outside = await makeRoot();
const nested = path.join(root, "nested");
await fs.mkdir(nested);
const originalOpen = fs.open.bind(fs);
const openSpy = vi.spyOn(fs, "open");
openSpy.mockImplementationOnce(async (file, flags, mode) => {
await fs.rm(nested, { recursive: true, force: true });
await fs.symlink(outside, nested);
return originalOpen(file, flags, mode);
});
try {
await expect(writePluginLocalFolderTextAtomic(root, "nested/page.md", "secret")).rejects.toMatchObject({
status: 403,
});
await expect(fs.readFile(path.join(outside, "page.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readdir(outside)).toEqual([]);
} finally {
openSpy.mockRestore();
}
});
});
@@ -0,0 +1,365 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentConfigRevisions,
agents,
approvals,
companies,
createDb,
pluginEntities,
pluginCompanySettings,
pluginManagedResources,
plugins,
} from "@paperclipai/db";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { buildHostServices } from "../services/plugin-host-services.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function createEventBusStub() {
return {
forPlugin() {
return {
emit: async () => {},
subscribe: () => {},
};
},
} as any;
}
function issuePrefix(id: string) {
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
}
function manifest(): PaperclipPluginManifestV1 {
return {
id: "paperclip.managed-agents-test",
apiVersion: 1,
version: "0.1.0",
displayName: "Managed Agents Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["agents.managed"],
entrypoints: { worker: "./dist/worker.js" },
agents: [
{
agentKey: "wiki-maintainer",
displayName: "Wiki Maintainer",
role: "engineer",
title: "Maintains plugin-owned knowledge",
capabilities: "Maintains a plugin-owned wiki.",
adapterType: "process",
adapterConfig: { command: "pnpm wiki:maintain" },
runtimeConfig: { modelProfiles: { cheap: { enabled: true, adapterConfig: { model: "small" } } } },
permissions: { canCreateAgents: false },
budgetMonthlyCents: 1234,
},
],
};
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres plugin-managed agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("plugin-managed agents", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-agents-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(agentConfigRevisions);
await db.delete(activityLog);
await db.delete(pluginEntities);
await db.delete(pluginManagedResources);
await db.delete(pluginCompanySettings);
await db.delete(approvals);
await db.delete(agents);
await db.delete(plugins);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompanyAndPlugin(options: { requireApproval?: boolean; manifest?: PaperclipPluginManifestV1 } = {}) {
const companyId = randomUUID();
const pluginId = randomUUID();
const pluginManifest = options.manifest ?? manifest();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: issuePrefix(companyId),
requireBoardApprovalForNewAgents: options.requireApproval ?? false,
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: pluginManifest.id,
packageName: "@paperclipai/plugin-managed-agents-test",
version: pluginManifest.version,
apiVersion: pluginManifest.apiVersion,
categories: pluginManifest.categories,
manifestJson: pluginManifest,
status: "ready",
installOrder: 1,
});
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
manifest: pluginManifest,
});
return { companyId, pluginId, pluginManifest, services };
}
it("creates and resolves managed agents by stable resource key", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const created = await services.agents.managedReconcile({
companyId,
agentKey: "wiki-maintainer",
});
expect(created.status).toBe("created");
expect(created.agentId).toBeTruthy();
expect(created.agent).toMatchObject({
name: "Wiki Maintainer",
role: "engineer",
adapterConfig: { command: "pnpm wiki:maintain" },
});
const resolved = await services.agents.managedGet({
companyId,
agentKey: "wiki-maintainer",
});
expect(resolved.status).toBe("resolved");
expect(resolved.agentId).toBe(created.agentId);
const [binding] = await db.select().from(pluginEntities);
expect(binding?.entityType).toBe("managed_agent");
expect(binding?.scopeKind).toBe("company");
expect(binding?.scopeId).toBe(companyId);
expect(binding?.data).toMatchObject({
resourceKind: "agent",
resourceKey: "wiki-maintainer",
agentId: created.agentId,
});
});
it("preserves user edits during reconcile and resets only on explicit reset", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(created.agentId).toBeTruthy();
await db
.update(agents)
.set({
name: "Knowledge Lead",
adapterConfig: { command: "custom" },
updatedAt: new Date(),
})
.where(eq(agents.id, created.agentId!));
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(reconciled.status).toBe("resolved");
expect(reconciled.agent).toMatchObject({
name: "Knowledge Lead",
adapterConfig: { command: "custom" },
});
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
expect(reset.status).toBe("reset");
expect(reset.agent).toMatchObject({
name: "Wiki Maintainer",
adapterConfig: { command: "pnpm wiki:maintain" },
});
});
it("creates managed agents with the most-used compatible company adapter", async () => {
const pluginManifest = manifest();
pluginManifest.agents![0] = {
...pluginManifest.agents![0]!,
adapterType: "claude_local",
adapterPreference: ["claude_local", "codex_local"],
adapterConfig: {},
};
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
await db.insert(agents).values([
{
id: randomUUID(),
companyId,
name: "Codex One",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: randomUUID(),
companyId,
name: "Codex Two",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: randomUUID(),
companyId,
name: "Claude One",
role: "engineer",
status: "idle",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(created.status).toBe("created");
expect(created.agent?.adapterType).toBe("codex_local");
});
it("materializes declared managed agent instructions with local folder paths", async () => {
const previousHome = process.env.PAPERCLIP_HOME;
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-home-"));
const wikiRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-wiki-")));
process.env.PAPERCLIP_HOME = tempHome;
process.env.PAPERCLIP_INSTANCE_ID = "test";
try {
const pluginManifest = manifest();
pluginManifest.localFolders = [
{
folderKey: "wiki-root",
displayName: "Wiki root",
access: "readWrite",
requiredDirectories: [],
requiredFiles: ["AGENTS.md"],
},
];
pluginManifest.agents![0] = {
...pluginManifest.agents![0]!,
adapterType: "claude_local",
adapterConfig: {},
instructions: {
entryFile: "AGENTS.md",
content: [
"# LLM Wiki Maintainer",
"",
"You are the LLM Wiki Maintainer.",
"Wiki root: `{{localFolders.wiki-root.path}}`",
"Wiki schema: `{{localFolders.wiki-root.agentsPath}}`",
"",
].join("\n"),
},
};
const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
await fs.writeFile(path.join(wikiRoot, "AGENTS.md"), "# Wiki schema\n", "utf8");
await db.insert(pluginCompanySettings).values({
companyId,
pluginId,
enabled: true,
settingsJson: {
localFolders: {
"wiki-root": {
path: wikiRoot,
access: "readWrite",
requiredDirectories: [],
requiredFiles: ["AGENTS.md"],
},
},
},
});
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const instructionsFilePath = created.agent?.adapterConfig.instructionsFilePath;
expect(typeof instructionsFilePath).toBe("string");
const content = await fs.readFile(instructionsFilePath as string, "utf8");
expect(content).toContain("You are the LLM Wiki Maintainer.");
expect(content).toContain(`Wiki root: \`${wikiRoot}\``);
expect(content).toContain(`Wiki schema: \`${path.join(wikiRoot, "AGENTS.md")}\``);
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousHome;
if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousInstance;
await fs.rm(tempHome, { recursive: true, force: true });
await fs.rm(wikiRoot, { recursive: true, force: true });
}
});
it("repairs a missing binding by relinking a same-company managed agent marker", async () => {
const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin();
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "Renamed Wiki Agent",
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: { command: "custom" },
runtimeConfig: {},
permissions: {},
metadata: {
paperclipManagedResource: {
pluginId,
pluginKey: pluginManifest.id,
resourceKind: "agent",
resourceKey: "wiki-maintainer",
},
},
});
const relinked = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(relinked.status).toBe("relinked");
expect(relinked.agentId).toBe(agentId);
const [binding] = await db.select().from(pluginEntities);
expect(binding?.data).toMatchObject({ agentId });
});
it("respects board approval policy for new managed agents", async () => {
const { companyId, services } = await seedCompanyAndPlugin({ requireApproval: true });
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(created.status).toBe("created");
expect(created.agent?.status).toBe("pending_approval");
expect(created.approvalId).toBeTruthy();
const [approval] = await db.select().from(approvals).where(eq(approvals.id, created.approvalId!));
expect(approval).toMatchObject({
type: "hire_agent",
status: "pending",
});
expect(approval?.payload).toMatchObject({
agentId: created.agentId,
sourcePluginKey: "paperclip.managed-agents-test",
managedResourceKey: "wiki-maintainer",
});
});
});
@@ -0,0 +1,249 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agentConfigRevisions,
agents,
companies,
createDb,
issues,
pluginManagedResources,
plugins,
projects,
routineRuns,
routineTriggers,
routines,
} from "@paperclipai/db";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { buildHostServices } from "../services/plugin-host-services.js";
import { routineService } from "../services/routines.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function createEventBusStub() {
return {
forPlugin() {
return {
emit: async () => {},
subscribe: () => {},
};
},
} as any;
}
function issuePrefix(id: string) {
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
}
function manifest(): PaperclipPluginManifestV1 {
return {
id: "paperclip.managed-routines-test",
apiVersion: 1,
version: "0.1.0",
displayName: "Managed Routines Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["agents.managed", "projects.managed", "routines.managed"],
entrypoints: { worker: "./dist/worker.js" },
agents: [{
agentKey: "wiki-maintainer",
displayName: "Wiki Maintainer",
role: "engineer",
adapterType: "process",
adapterConfig: { command: "pnpm wiki:maintain" },
}],
projects: [{
projectKey: "operations",
displayName: "Plugin Operations",
description: "Plugin operation inspection",
status: "in_progress",
}],
routines: [{
routineKey: "nightly-lint",
title: "Nightly lint",
description: "Lint plugin state",
assigneeRef: { resourceKind: "agent", resourceKey: "wiki-maintainer" },
projectRef: { resourceKind: "project", resourceKey: "operations" },
status: "active",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
triggers: [{
kind: "schedule",
label: "Nightly",
cronExpression: "0 3 * * *",
timezone: "UTC",
}],
issueTemplate: {
surfaceVisibility: "plugin_operation",
originId: "operation:nightly-lint",
billingCode: "plugin-test:nightly-lint",
},
}],
};
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres plugin-managed routine tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("plugin-managed routines", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-routines-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(routines);
await db.delete(issues);
await db.delete(agentConfigRevisions);
await db.delete(activityLog);
await db.delete(pluginManagedResources);
await db.delete(agents);
await db.delete(projects);
await db.delete(plugins);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompanyAndPlugin(pluginManifest = manifest()) {
const companyId = randomUUID();
const pluginId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: issuePrefix(companyId),
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: pluginManifest.id,
packageName: "@paperclipai/plugin-managed-routines-test",
version: pluginManifest.version,
apiVersion: pluginManifest.apiVersion,
categories: pluginManifest.categories,
manifestJson: pluginManifest,
status: "ready",
installOrder: 1,
});
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
manifest: pluginManifest,
});
return { companyId, pluginId, pluginManifest, services };
}
it("resolves routine agent and project refs by stable managed keys", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
const created = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(created.status).toBe("created");
expect(created.routine).toMatchObject({
title: "Nightly lint",
assigneeAgentId: agent.agentId,
projectId: project.projectId,
managedByPlugin: expect.objectContaining({
pluginKey: "paperclip.managed-routines-test",
resourceKind: "routine",
resourceKey: "nightly-lint",
}),
});
const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, created.routineId!));
expect(trigger).toMatchObject({
kind: "schedule",
cronExpression: "0 3 * * *",
timezone: "UTC",
});
});
it("returns missing refs until the operator repairs them and preserves routine edits on reconcile", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const missing = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(missing.status).toBe("missing_refs");
expect(missing.missingRefs).toEqual([
expect.objectContaining({ resourceKind: "agent", resourceKey: "wiki-maintainer" }),
expect.objectContaining({ resourceKind: "project", resourceKey: "operations" }),
]);
const [agent] = await db.insert(agents).values({
companyId,
name: "Operator-selected maintainer",
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
}).returning();
const [project] = await db.insert(projects).values({
companyId,
name: "Operator-selected project",
status: "in_progress",
}).returning();
const repaired = await services.routines.managedReconcile({
companyId,
routineKey: "nightly-lint",
assigneeAgentId: agent.id,
projectId: project.id,
});
expect(repaired.status).toBe("created");
expect(repaired.routine).toMatchObject({
assigneeAgentId: agent.id,
projectId: project.id,
});
await db
.update(routines)
.set({ title: "Operator renamed lint", updatedAt: new Date() })
.where(eq(routines.id, repaired.routineId!));
const reconciled = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(reconciled.status).toBe("resolved");
expect(reconciled.routine?.title).toBe("Operator renamed lint");
});
it("creates routine operation issues with plugin visibility and managed project scoping", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
const routine = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
const wakeup = vi.fn(async () => ({ id: randomUUID() }));
const routinesSvc = routineService(db, { heartbeat: { wakeup } });
const run = await routinesSvc.runRoutine(routine.routineId!, { source: "manual" }, { userId: "board-user" });
expect(run.status).toBe("issue_created");
const [issue] = await db.select().from(issues).where(eq(issues.id, run.linkedIssueId!));
expect(issue).toMatchObject({
originKind: "plugin:paperclip.managed-routines-test:operation",
originId: "operation:nightly-lint",
billingCode: "plugin-test:nightly-lint",
projectId: project.projectId,
assigneeAgentId: agent.agentId,
});
expect(wakeup).toHaveBeenCalledWith(agent.agentId, expect.objectContaining({
reason: "issue_assigned",
}));
});
});
@@ -0,0 +1,281 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
companies,
companySkills,
createDb,
pluginManagedResources,
plugins,
} from "@paperclipai/db";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { buildHostServices } from "../services/plugin-host-services.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function createEventBusStub() {
return {
forPlugin() {
return {
emit: async () => {},
subscribe: () => {},
};
},
} as any;
}
function issuePrefix(id: string) {
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
}
function manifest(): PaperclipPluginManifestV1 {
return {
id: "paperclip.managed-skills-test",
apiVersion: 1,
version: "0.1.0",
displayName: "Managed Skills Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["skills.managed"],
entrypoints: { worker: "./dist/worker.js" },
skills: [{
skillKey: "wiki-maintainer",
displayName: "Wiki Maintainer Skill",
description: "Use LLM Wiki tools to maintain company knowledge.",
files: [{
path: "references/wiki-style.md",
content: "# Wiki style\n\nKeep pages cited and terse.\n",
}],
}],
};
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres plugin-managed skill tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("plugin-managed skills", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-skills-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(pluginManagedResources);
await db.delete(companySkills);
await db.delete(plugins);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompanyAndPlugin(pluginManifest = manifest()) {
const companyId = randomUUID();
const pluginId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: issuePrefix(companyId),
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: pluginManifest.id,
packageName: "@paperclipai/plugin-managed-skills-test",
version: pluginManifest.version,
apiVersion: pluginManifest.apiVersion,
categories: pluginManifest.categories,
manifestJson: pluginManifest,
status: "ready",
installOrder: 1,
});
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
manifest: pluginManifest,
});
return { companyId, pluginId, pluginManifest, services };
}
it("installs and resolves managed company skills by stable resource key", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const created = await services.skills.managedReconcile({
companyId,
skillKey: "wiki-maintainer",
});
expect(created.status).toBe("created");
expect(created.skill).toMatchObject({
name: "Wiki Maintainer Skill",
key: "plugin/paperclip-managed-skills-test/wiki-maintainer",
sourceType: "catalog",
fileInventory: expect.arrayContaining([
expect.objectContaining({ path: "SKILL.md", kind: "skill" }),
expect.objectContaining({ path: "references/wiki-style.md", kind: "reference" }),
]),
});
const resolved = await services.skills.managedGet({
companyId,
skillKey: "wiki-maintainer",
});
expect(resolved.status).toBe("resolved");
expect(resolved.skillId).toBe(created.skillId);
const [binding] = await db.select().from(pluginManagedResources);
expect(binding).toMatchObject({
companyId,
resourceKind: "skill",
resourceKey: "wiki-maintainer",
resourceId: created.skillId,
});
});
it("preserves operator edits during reconcile and restores manifest defaults on reset", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
expect(created.skillId).toBeTruthy();
await db
.update(companySkills)
.set({
name: "Custom Wiki Skill",
markdown: "# Custom instructions\n",
updatedAt: new Date(),
})
.where(eq(companySkills.id, created.skillId!));
const reconciled = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
expect(reconciled.status).toBe("resolved");
expect(reconciled.skill).toMatchObject({
name: "Custom Wiki Skill",
markdown: "# Custom instructions\n",
});
const reset = await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" });
expect(reset.status).toBe("reset");
expect(reset.skill).toMatchObject({
name: "Wiki Maintainer Skill",
description: "Use LLM Wiki tools to maintain company knowledge.",
});
expect(reset.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/wiki-maintainer\"");
});
it("does not rewrite managed skill bindings when defaults are unchanged", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
expect(created.skillId).toBeTruthy();
const [binding] = await db.select().from(pluginManagedResources);
const oldUpdatedAt = new Date("2026-01-01T00:00:00.000Z");
await db
.update(pluginManagedResources)
.set({ updatedAt: oldUpdatedAt })
.where(eq(pluginManagedResources.id, binding.id));
const reconciled = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
const [bindingAfter] = await db.select().from(pluginManagedResources);
expect(reconciled.status).toBe("resolved");
expect(bindingAfter.updatedAt.toISOString()).toBe(oldUpdatedAt.toISOString());
});
it("relinks an existing canonical skill without overwriting operator edits", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
expect(created.skillId).toBeTruthy();
await db.delete(pluginManagedResources).where(eq(pluginManagedResources.resourceId, created.skillId!));
await db
.update(companySkills)
.set({
name: "Existing Customized Wiki Skill",
markdown: "# Existing customized instructions\n",
updatedAt: new Date(),
})
.where(eq(companySkills.id, created.skillId!));
const relinked = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
expect(relinked.status).toBe("relinked");
expect(relinked.skillId).toBe(created.skillId);
expect(relinked.skill).toMatchObject({
name: "Existing Customized Wiki Skill",
markdown: "# Existing customized instructions\n",
});
expect(relinked.defaultDrift).toEqual({ changedFiles: ["SKILL.md"] });
const [binding] = await db.select().from(pluginManagedResources);
expect(binding).toMatchObject({
companyId,
resourceKind: "skill",
resourceKey: "wiki-maintainer",
resourceId: created.skillId,
});
});
it("reports drift when installed skill files differ from plugin defaults", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
expect(created.defaultDrift).toBeNull();
await db
.update(companySkills)
.set({
markdown: "# Custom instructions\n",
updatedAt: new Date(),
})
.where(eq(companySkills.id, created.skillId!));
const drifted = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
expect(drifted.status).toBe("resolved");
expect(drifted.defaultDrift).toEqual({ changedFiles: ["SKILL.md"] });
const reset = await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" });
expect(reset.defaultDrift).toBeNull();
});
it("adds the canonical managed key to manifest-provided markdown skills", async () => {
const pluginManifest = manifest();
pluginManifest.skills = [
...(pluginManifest.skills ?? []),
{
skillKey: "markdown-skill",
displayName: "Markdown Skill",
markdown: [
"---",
"name: markdown-skill",
"description: Markdown source without a key.",
"---",
"",
"# Markdown Skill",
"",
"Follow the managed markdown.",
].join("\n"),
},
];
const { companyId, services } = await seedCompanyAndPlugin(pluginManifest);
const created = await services.skills.managedReconcile({ companyId, skillKey: "markdown-skill" });
expect(created.status).toBe("created");
expect(created.skill).toMatchObject({
key: "plugin/paperclip-managed-skills-test/markdown-skill",
name: "markdown-skill",
});
expect(created.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/markdown-skill\"");
});
});
@@ -1,4 +1,7 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
@@ -11,6 +14,9 @@ import {
heartbeatRuns,
issueRelations,
issues,
pluginManagedResources,
plugins,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -45,6 +51,7 @@ if (!embeddedPostgresSupport.supported) {
describeEmbeddedPostgres("plugin orchestration APIs", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempRoots: string[] = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-");
@@ -52,12 +59,17 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
}, 20_000);
afterEach(async () => {
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
tempRoots.length = 0;
await db.delete(activityLog);
await db.delete(costEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(issueRelations);
await db.delete(issues);
await db.delete(pluginManagedResources);
await db.delete(projects);
await db.delete(plugins);
await db.delete(agents);
await db.delete(companies);
});
@@ -89,6 +101,12 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
return { companyId, agentId };
}
async function makeLocalRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-host-folder-"));
tempRoots.push(root);
return root;
}
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const blockerIssueId = randomUUID();
@@ -189,6 +207,293 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
});
it("creates plugin operation issues with the generic operation origin", async () => {
const { companyId } = await seedCompanyAndAgent();
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
const issue = await services.issues.create({
companyId,
title: "Background operation",
surfaceVisibility: "plugin_operation",
originId: "mission-alpha:operation-1",
});
expect(issue.originKind).toBe("plugin:paperclip.missions:operation");
expect(issue.originId).toBe("mission-alpha:operation-1");
});
it("lets bootstrap-style actions initialize required local folders from an empty root", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclipai.plugin-llm-wiki",
packageName: "@paperclipai/plugin-llm-wiki",
version: "0.1.0",
manifestJson: {
id: "paperclipai.plugin-llm-wiki",
apiVersion: 1,
version: "0.1.0",
displayName: "LLM Wiki",
description: "Local-file LLM Wiki plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "wiki-root",
displayName: "Wiki root",
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
},
],
},
status: "ready",
});
const root = await makeLocalRoot();
const services = buildHostServices(
db,
pluginId,
"paperclipai.plugin-llm-wiki",
createEventBusStub(),
undefined,
{
manifest: {
id: "paperclipai.plugin-llm-wiki",
apiVersion: 1,
version: "0.1.0",
displayName: "LLM Wiki",
description: "Local-file LLM Wiki plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "wiki-root",
displayName: "Wiki root",
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
},
],
},
},
);
const configured = await services.localFolders.configure({
companyId,
folderKey: "wiki-root",
path: root,
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
});
expect(configured.healthy).toBe(false);
expect(configured.missingDirectories).toEqual([]);
expect(configured.missingFiles).toEqual(["WIKI.md", "AGENTS.md"]);
await fs.rm(path.join(root, "raw"), { recursive: true, force: true });
await fs.rm(path.join(root, "wiki"), { recursive: true, force: true });
await expect(services.localFolders.readText({ companyId, folderKey: "wiki-root", relativePath: "WIKI.md" }))
.rejects.toThrow("Local folder is not healthy");
await services.localFolders.writeTextAtomic({
companyId,
folderKey: "wiki-root",
relativePath: "WIKI.md",
contents: "# Wiki\n",
});
await services.localFolders.writeTextAtomic({
companyId,
folderKey: "wiki-root",
relativePath: "AGENTS.md",
contents: "# Agents\n",
});
const finalStatus = await services.localFolders.status({ companyId, folderKey: "wiki-root" });
expect(finalStatus.healthy).toBe(true);
await expect(fs.stat(path.join(root, "raw"))).resolves.toMatchObject({});
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
await expect(fs.readFile(path.join(root, "WIKI.md"), "utf8")).resolves.toBe("# Wiki\n");
});
it("rejects worker local-folder access for undeclared manifest keys", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.local-folders",
packageName: "@paperclip/plugin-local-folders",
version: "0.1.0",
manifestJson: {
id: "paperclip.local-folders",
apiVersion: 1,
version: "0.1.0",
displayName: "Local Folders",
description: "Local folder fixture",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
},
],
},
status: "ready",
});
const services = buildHostServices(
db,
pluginId,
"paperclip.local-folders",
createEventBusStub(),
undefined,
{
manifest: {
id: "paperclip.local-folders",
apiVersion: 1,
version: "0.1.0",
displayName: "Local Folders",
description: "Local folder fixture",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
},
],
},
},
);
await expect(services.localFolders.configure({
companyId,
folderKey: "ssh",
path: "/tmp",
access: "read",
})).rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.status({ companyId, folderKey: "ssh" }))
.rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.readText({ companyId, folderKey: "ssh", relativePath: "id_rsa" }))
.rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.writeTextAtomic({
companyId,
folderKey: "ssh",
relativePath: "id_rsa",
contents: "secret",
})).rejects.toThrow("Local folder key is not declared");
});
it("resolves plugin-managed projects by stable key without overwriting user edits", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.missions",
packageName: "@paperclip/plugin-missions",
version: "0.1.0",
apiVersion: 1,
categories: ["automation"],
status: "ready",
manifestJson: {
id: "paperclip.missions",
apiVersion: 1,
version: "0.1.0",
displayName: "Missions",
description: "Mission orchestration",
author: "Paperclip",
categories: ["automation"],
capabilities: ["projects.managed"],
entrypoints: { worker: "./dist/worker.js" },
projects: [{
projectKey: "operations",
displayName: "Mission Operations",
description: "Plugin operation inspection area",
status: "in_progress",
color: "#14b8a6",
settings: { surface: "operations" },
}],
},
});
const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub());
const missing = await services.projects.getManaged({ companyId, projectKey: "operations" });
expect(missing.status).toBe("missing");
expect(missing.projectId).toBeNull();
await expect(
db
.select()
.from(pluginManagedResources)
.where(and(
eq(pluginManagedResources.companyId, companyId),
eq(pluginManagedResources.pluginId, pluginId),
eq(pluginManagedResources.resourceKind, "project"),
eq(pluginManagedResources.resourceKey, "operations"),
)),
).resolves.toHaveLength(0);
const created = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
expect(created.status).toBe("created");
expect(created.projectId).toEqual(expect.any(String));
expect(created.project?.managedByPlugin).toMatchObject({
pluginId,
pluginKey: "paperclip.missions",
pluginDisplayName: "Missions",
resourceKind: "project",
resourceKey: "operations",
});
await db
.update(projects)
.set({ name: "Renamed by operator", description: "User-owned text", updatedAt: new Date() })
.where(eq(projects.id, created.projectId!));
await db
.update(plugins)
.set({
manifestJson: {
id: "paperclip.missions",
apiVersion: 1,
version: "0.2.0",
displayName: "Missions",
description: "Mission orchestration",
author: "Paperclip",
categories: ["automation"],
capabilities: ["projects.managed"],
entrypoints: { worker: "./dist/worker.js" },
projects: [{
projectKey: "operations",
displayName: "Upgraded Default Name",
description: "Upgraded default description",
status: "planned",
color: "#f97316",
settings: { surface: "operations", upgraded: true },
}],
},
updatedAt: new Date(),
})
.where(eq(plugins.id, pluginId));
const resolved = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
expect(resolved.status).toBe("resolved");
expect(resolved.projectId).toBe(created.projectId);
expect(resolved.project?.name).toBe("Renamed by operator");
expect(resolved.project?.description).toBe("User-owned text");
expect(resolved.project?.managedByPlugin?.defaultsJson).toMatchObject({
displayName: "Upgraded Default Name",
settings: { upgraded: true },
});
});
it("asserts checkout ownership for run-scoped plugin actions", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
@@ -6,6 +6,8 @@ const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
getByKey: vi.fn(),
upsertConfig: vi.fn(),
getCompanySettings: vi.fn(),
upsertCompanySettings: vi.fn(),
}));
const mockLifecycle = vi.hoisted(() => ({
@@ -222,6 +224,30 @@ describe.sequential("plugin install and upgrade authz", () => {
expect(mockLifecycle.disable).not.toHaveBeenCalled();
}, 20_000);
it("rejects plugin config saves that contain secret refs even for instance admins", async () => {
readyPlugin();
const { app } = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [companyA],
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/config`)
.send({
configJson: {
apiKeyRef: "77777777-7777-4777-8777-777777777777",
},
});
expect(res.status).toBe(422);
expect(res.body.error).toMatch(/secret references are disabled/i);
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
}, 20_000);
it("allows instance admins to upgrade plugins", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
mockRegistry.getById.mockResolvedValue({
@@ -317,6 +343,61 @@ describe.sequential("scoped plugin API routes", () => {
}, 20_000);
});
describe.sequential("plugin local folder routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRegistry.getCompanySettings.mockResolvedValue(null);
});
function readyLocalFolderPlugin() {
mockRegistry.getById.mockResolvedValue({
id: pluginId,
pluginKey: "paperclip.example",
version: "1.0.0",
status: "ready",
manifestJson: {
id: "paperclip.example",
capabilities: ["local.folders"],
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
requiredDirectories: ["docs"],
requiredFiles: ["README.md"],
},
],
},
});
}
it("rejects validation for undeclared local folder keys", async () => {
readyLocalFolderPlugin();
const { app } = await createApp(boardActor());
const res = await request(app)
.post(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh/validate`)
.send({ path: "/tmp" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("Local folder key is not declared");
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
});
it("rejects saving undeclared local folder keys", async () => {
readyLocalFolderPlugin();
const { app } = await createApp(boardActor());
const res = await request(app)
.put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh`)
.send({ path: "/tmp" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("Local folder key is not declared");
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
});
});
describe.sequential("plugin tool and bridge authz", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -98,6 +98,7 @@ describe.sequential("plugin scoped API routes", () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const agentId = "33333333-3333-4333-8333-333333333333";
const peerAgentId = "33333333-3333-4333-8333-333333333334";
const runId = "44444444-4444-4444-8444-444444444444";
const issueId = "55555555-5555-4555-8555-555555555555";
@@ -252,6 +253,55 @@ describe.sequential("plugin scoped API routes", () => {
}));
});
it("allows non-assignee agents on in-progress required checkout routes without claiming checkout ownership", async () => {
const apiRoutes = manifest([
{
routeKey: "issue.advance",
method: "POST",
path: "/issues/:issueId/advance",
auth: "agent",
capability: "api.routes.register",
checkoutPolicy: "required-for-agent-in-progress",
companyResolution: { from: "issue", param: "issueId" },
},
]);
mockIssueService.getById.mockResolvedValue({
id: issueId,
companyId,
status: "in_progress",
assigneeAgentId: agentId,
});
const { app, workerManager } = await createApp({
actor: {
type: "agent",
agentId: peerAgentId,
companyId,
runId,
source: "agent_key",
},
plugin: {
id: pluginId,
pluginKey: apiRoutes.id,
status: "ready",
manifestJson: apiRoutes,
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`)
.send({ step: "next" });
expect(res.status).toBe(200);
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({
routeKey: "issue.advance",
params: { issueId },
body: { step: "next" },
actor: expect.objectContaining({ actorType: "agent", agentId: peerAgentId, runId }),
companyId,
}));
});
it("rejects checkout-protected agent routes without a run id before worker dispatch", async () => {
const apiRoutes = manifest([
{
@@ -150,6 +150,23 @@ describe("plugin SDK orchestration contract", () => {
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.test-orchestration");
});
it("supports generic plugin operation issue visibility in the test harness", async () => {
const companyId = randomUUID();
const harness = createTestHarness({
manifest: manifest(["issues.create"]),
});
const created = await harness.ctx.issues.create({
companyId,
title: "Background operation",
surfaceVisibility: "plugin_operation",
originId: "operation-1",
});
expect(created.originKind).toBe("plugin:paperclip.test-orchestration:operation");
expect(created.originId).toBe("operation-1");
});
it("enforces checkout and wakeup capabilities in the test harness", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
describe("plugin SDK test harness", () => {
it("requires skills.managed capability before resetting a missing declaration", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-missing-managed-skill-capability",
apiVersion: 1,
version: "0.1.0",
displayName: "Missing Managed Skill Capability",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "./dist/worker.js" },
skills: [{
skillKey: "wiki-maintainer",
displayName: "Wiki Maintainer",
}],
};
const harness = createTestHarness({ manifest });
await expect(harness.ctx.skills.managed.reset("unknown-skill", "company-1")).rejects.toThrow(
"missing required capability 'skills.managed'",
);
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
createPluginSecretsHandler,
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
} from "../services/plugin-secrets-handler.js";
describe("createPluginSecretsHandler", () => {
it("fails closed for plugin secret resolution until company scoping lands", async () => {
const handler = createPluginSecretsHandler({
db: {} as never,
pluginId: "11111111-1111-4111-8111-111111111111",
});
await expect(
handler.resolve({ secretRef: "77777777-7777-4777-8777-777777777777" }),
).rejects.toThrow(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
});
it("still rejects malformed secret refs before the feature-disable guard", async () => {
const handler = createPluginSecretsHandler({
db: {} as never,
pluginId: "11111111-1111-4111-8111-111111111111",
});
await expect(
handler.resolve({ secretRef: "not-a-uuid" }),
).rejects.toThrow(/invalid secret reference/i);
});
});
@@ -1,5 +1,32 @@
import { describe, expect, it } from "vitest";
import { appendStderrExcerpt, formatWorkerFailureMessage } from "../services/plugin-worker-manager.js";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
JsonRpcCallError,
type HostToWorkerMethods,
} from "@paperclipai/plugin-sdk";
import {
appendStderrExcerpt,
createPluginWorkerHandle,
formatWorkerFailureMessage,
} from "../services/plugin-worker-manager.js";
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs");
const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs");
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
id: "test.plugin",
apiVersion: 1,
version: "1.0.0",
displayName: "Test plugin",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "dist/worker.js" },
};
describe("plugin-worker-manager stderr failure context", () => {
it("appends worker stderr context to failure messages", () => {
@@ -40,4 +67,115 @@ describe("plugin-worker-manager stderr failure context", () => {
expect(excerpt).not.toContain("second line");
expect(excerpt.length).toBeLessThanOrEqual(8_000);
});
it("times out environmentExecute calls using the handle default when no override is provided", async () => {
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: DELAYED_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: {
instanceId: "instance-1",
hostVersion: "1.0.0",
},
apiVersion: 1,
hostHandlers: {},
rpcTimeoutMs: 10,
});
try {
await handle.start();
await expect(handle.call("environmentExecute", {
driverKey: "e2b",
companyId: "company-1",
environmentId: "environment-1",
config: {},
lease: { providerLeaseId: "lease-1" },
command: "echo",
delayMs: 50,
} as HostToWorkerMethods["environmentExecute"][0])).rejects.toMatchObject({
message: expect.stringContaining("timed out after 10ms"),
});
} finally {
await handle.stop().catch(() => undefined);
}
});
it("honors per-call timeout overrides for environmentExecute", async () => {
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: DELAYED_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: {
instanceId: "instance-1",
hostVersion: "1.0.0",
},
apiVersion: 1,
hostHandlers: {},
rpcTimeoutMs: 10,
});
try {
await handle.start();
await expect(handle.call("environmentExecute", {
driverKey: "e2b",
companyId: "company-1",
environmentId: "environment-1",
config: {},
lease: { providerLeaseId: "lease-1" },
command: "echo",
delayMs: 50,
} as HostToWorkerMethods["environmentExecute"][0], 100)).resolves.toMatchObject({
exitCode: 0,
stdout: "ok\n",
});
} finally {
await handle.stop().catch(() => undefined);
}
});
it("does not emit an unhandled rejection when a plugin responds with terminated before callers attach handlers", async () => {
const unhandledRejection = vi.fn();
process.on("unhandledRejection", unhandledRejection);
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: TERMINATED_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: {
instanceId: "instance-1",
hostVersion: "1.0.0",
},
apiVersion: 1,
hostHandlers: {},
});
try {
await handle.start();
const pendingCall = handle.call(
"environmentExecute" as keyof HostToWorkerMethods,
{
driverKey: "e2b",
companyId: "company-1",
environmentId: "environment-1",
config: {},
lease: { providerLeaseId: "lease-1" },
command: "echo",
} as HostToWorkerMethods[keyof HostToWorkerMethods][0],
);
await new Promise((resolve) => setImmediate(resolve));
await expect(pendingCall).rejects.toBeInstanceOf(JsonRpcCallError);
await expect(pendingCall).rejects.toMatchObject({
message: expect.stringContaining("terminated"),
});
expect(unhandledRejection).not.toHaveBeenCalled();
} finally {
process.off("unhandledRejection", unhandledRejection);
await handle.stop().catch(() => undefined);
}
});
});
@@ -201,6 +201,7 @@ describeEmbeddedPostgres("productivity review service", () => {
expect(reviews).toHaveLength(1);
expect(reviews[0]?.parentId).toBe(seeded.issueId);
expect(reviews[0]?.assigneeAgentId).toBe(seeded.managerId);
expect(reviews[0]?.assigneeAdapterOverrides).toEqual({ modelProfile: "cheap" });
expect(reviews[0]?.originId).toBe(seeded.issueId);
expect(reviews[0]?.originFingerprint).toBe(`productivity-review:${seeded.issueId}`);
expect(reviews[0]?.description).toContain("Primary trigger: `no_comment_streak`");
@@ -74,6 +74,100 @@ describe("recovery classifier boundary", () => {
expect(classifyIssueGraphLiveness(input)).toEqual(classifyIssueGraphLivenessCompat(input));
});
it("treats a scheduled monitor as an explicit review action path", () => {
const findings = classifyIssueGraphLiveness({
now: "2026-04-30T18:00:00.000Z",
issues: [
{
id: issueId,
companyId,
identifier: "PAP-2945",
title: "Wait for external review",
status: "in_review",
assigneeAgentId: agentId,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
executionState: null,
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
},
],
relations: [],
agents: [
{
id: agentId,
companyId,
name: "Coder",
role: "engineer",
status: "idle",
reportsTo: managerId,
},
],
});
expect(findings).toEqual([]);
});
it("does not treat overdue or exhausted monitors as explicit waiting paths", () => {
const baseIssue = {
id: issueId,
companyId,
identifier: "PAP-2945",
title: "Wait for external review",
status: "in_review",
assigneeAgentId: agentId,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
};
const agents = [
{
id: agentId,
companyId,
name: "Coder",
role: "engineer",
status: "idle",
reportsTo: managerId,
},
];
const overdue = classifyIssueGraphLiveness({
now: "2026-04-30T20:00:00.000Z",
issues: [
{
...baseIssue,
executionState: null,
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
},
],
relations: [],
agents,
});
const exhausted = classifyIssueGraphLiveness({
now: "2026-04-30T18:00:00.000Z",
issues: [
{
...baseIssue,
executionPolicy: {
monitor: {
nextCheckAt: "2026-04-30T19:00:00.000Z",
maxAttempts: 1,
},
},
executionState: null,
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
monitorAttemptCount: 1,
},
],
relations: [],
agents,
});
expect(overdue[0]?.state).toBe("in_review_without_action_path");
expect(exhausted[0]?.state).toBe("in_review_without_action_path");
});
it("keeps run liveness continuation decision parity with the compatibility export", () => {
const input = {
run: {
@@ -7,6 +7,7 @@ const agentId = "11111111-1111-4111-8111-111111111111";
const routineId = "33333333-3333-4333-8333-333333333333";
const projectId = "44444444-4444-4444-8444-444444444444";
const otherAgentId = "55555555-5555-4555-8555-555555555555";
const revisionId = "77777777-7777-4777-8777-777777777777";
const routine = {
id: routineId,
@@ -21,6 +22,9 @@ const routine = {
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
@@ -30,6 +34,40 @@ const routine = {
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
};
const revision = {
id: revisionId,
companyId,
routineId,
revisionNumber: 1,
title: "Daily routine",
description: null,
snapshot: {
version: 1,
routine: {
id: routineId,
companyId,
projectId,
goalId: null,
parentIssueId: null,
title: "Daily routine",
description: null,
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
},
triggers: [],
},
changeSummary: "Created routine",
restoredFromRevisionId: null,
createdByAgentId: null,
createdByUserId: "board-user",
createdByRunId: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
};
const pausedRoutine = {
...routine,
status: "paused",
@@ -65,6 +103,8 @@ const mockRoutineService = vi.hoisted(() => ({
getDetail: vi.fn(),
update: vi.fn(),
create: vi.fn(),
listRevisions: vi.fn(),
restoreRevision: vi.fn(),
listRuns: vi.fn(),
createTrigger: vi.fn(),
getTrigger: vi.fn(),
@@ -150,6 +190,14 @@ describe("routine routes", () => {
mockRoutineService.get.mockResolvedValue(routine);
mockRoutineService.getTrigger.mockResolvedValue(trigger);
mockRoutineService.update.mockResolvedValue({ ...routine, assigneeAgentId: otherAgentId });
mockRoutineService.listRevisions.mockResolvedValue([revision]);
mockRoutineService.restoreRevision.mockResolvedValue({
routine,
revision: { ...revision, revisionNumber: 2, restoredFromRevisionId: revision.id },
restoredFromRevisionId: revision.id,
restoredFromRevisionNumber: revision.revisionNumber,
secretMaterials: [],
});
mockRoutineService.runRoutine.mockResolvedValue({
id: "run-1",
source: "manual",
@@ -176,6 +224,73 @@ describe("routine routes", () => {
expect(mockRoutineService.list).toHaveBeenCalledWith(companyId, { projectId });
});
it("lists routine revisions for a board member in newest-first service order", async () => {
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await request(app).get(`/api/routines/${routineId}/revisions`);
expect(res.status).toBe(200);
expect(mockRoutineService.listRevisions).toHaveBeenCalledWith(routineId);
expect(res.body[0]).toMatchObject({ id: revisionId, revisionNumber: 1 });
});
it("blocks routine revision reads across company scope", async () => {
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: ["99999999-9999-4999-8999-999999999999"],
});
const res = await request(app).get(`/api/routines/${routineId}/revisions`);
expect(res.status).toBe(403);
expect(mockRoutineService.listRevisions).not.toHaveBeenCalled();
});
it("requires an assigned agent for routine revision history access", async () => {
const app = await createApp({
type: "agent",
agentId: otherAgentId,
companyId,
});
const res = await request(app).get(`/api/routines/${routineId}/revisions`);
expect(res.status).toBe(403);
expect(mockRoutineService.listRevisions).not.toHaveBeenCalled();
});
it("restores routine revisions with existing routine-management permissions", async () => {
const app = await createApp({
type: "agent",
agentId,
companyId,
runId: "88888888-8888-4888-8888-888888888888",
});
const res = await request(app).post(`/api/routines/${routineId}/revisions/${revisionId}/restore`).send({});
expect(res.status).toBe(200);
expect(mockRoutineService.restoreRevision).toHaveBeenCalledWith(routineId, revisionId, {
agentId,
userId: null,
runId: "88888888-8888-4888-8888-888888888888",
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "routine.revision_restored",
entityId: routineId,
runId: "88888888-8888-4888-8888-888888888888",
}));
});
it("requires tasks:assign permission for non-admin board routine creation", async () => {
const app = await createApp({
type: "board",
@@ -348,6 +463,7 @@ describe("routine routes", () => {
}), {
agentId: null,
userId: "board-user",
runId: null,
});
expect(mockTrackRoutineCreated).toHaveBeenCalledWith(expect.anything());
});
+279 -1
View File
@@ -1,6 +1,6 @@
import { createHmac, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
@@ -26,10 +26,12 @@ import {
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import * as providerRegistry from "../secrets/provider-registry.ts";
import { routineService } from "../services/routines.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
if (!embeddedPostgresSupport.supported) {
console.warn(
@@ -47,6 +49,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
}, 20_000);
afterEach(async () => {
if (originalSecretsProviderEnv === undefined) {
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
} else {
process.env.PAPERCLIP_SECRETS_PROVIDER = originalSecretsProviderEnv;
}
await db.delete(activityLog);
await db.delete(issueInboxArchives);
await db.delete(issueReadStates);
@@ -283,6 +290,201 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(routine.status).toBe("paused");
});
it("creates revision 1 on routine create and appends revisions for real updates only", async () => {
const { routine, svc } = await seedFixture();
const initialRevisions = await svc.listRevisions(routine.id);
expect(initialRevisions).toHaveLength(1);
expect(initialRevisions[0]).toMatchObject({
id: routine.latestRevisionId,
revisionNumber: 1,
title: "ascii frog",
changeSummary: "Created routine",
});
expect(initialRevisions[0]?.snapshot.routine.description).toBe("Run the frog routine");
const updated = await svc.update(
routine.id,
{
description: "Run the frog routine with logs",
baseRevisionId: routine.latestRevisionId,
},
{},
);
expect(updated?.latestRevisionNumber).toBe(2);
expect(updated?.latestRevisionId).not.toBe(routine.latestRevisionId);
const noOp = await svc.update(
routine.id,
{
description: "Run the frog routine with logs",
baseRevisionId: updated?.latestRevisionId,
},
{},
);
expect(noOp?.latestRevisionId).toBe(updated?.latestRevisionId);
expect(noOp?.latestRevisionNumber).toBe(2);
const revisions = await svc.listRevisions(routine.id);
expect(revisions.map((revision) => revision.revisionNumber)).toEqual([2, 1]);
expect(revisions[0]?.snapshot.routine.description).toBe("Run the frog routine with logs");
expect(revisions[1]?.snapshot.routine.description).toBe("Run the frog routine");
});
it("rejects stale routine baseRevisionId updates", async () => {
const { routine, svc } = await seedFixture();
const updated = await svc.update(routine.id, { description: "new description" }, {});
await expect(
svc.update(routine.id, {
title: "stale update",
baseRevisionId: routine.latestRevisionId,
}, {}),
).rejects.toMatchObject({
status: 409,
details: {
currentRevisionId: updated?.latestRevisionId,
},
});
});
it("restores an older routine revision append-only and preserves run history", async () => {
const { routine, svc } = await seedFixture();
const revision1Id = routine.latestRevisionId!;
const run = await svc.runRoutine(routine.id, { source: "manual" });
const revision2Routine = await svc.update(routine.id, { description: "revision 2" }, {});
const restored = await svc.restoreRevision(routine.id, revision1Id, {});
expect(restored.restoredFromRevisionId).toBe(revision1Id);
expect(restored.restoredFromRevisionNumber).toBe(1);
expect(restored.routine.latestRevisionNumber).toBe(3);
expect(restored.routine.latestRevisionId).not.toBe(revision2Routine?.latestRevisionId);
expect(restored.routine.description).toBe("Run the frog routine");
expect(restored.revision.restoredFromRevisionId).toBe(revision1Id);
expect(restored.revision.snapshot.routine.description).toBe("Run the frog routine");
const revisions = await svc.listRevisions(routine.id);
expect(revisions.map((revision) => revision.revisionNumber)).toEqual([3, 2, 1]);
await expect(db.select().from(routineRuns).where(eq(routineRuns.id, run.id))).resolves.toHaveLength(1);
});
it("rejects restoring the current latest routine revision", async () => {
const { routine, svc } = await seedFixture();
await expect(
svc.restoreRevision(routine.id, routine.latestRevisionId!, {}),
).rejects.toMatchObject({
status: 409,
details: {
currentRevisionId: routine.latestRevisionId,
},
});
});
it("recreates deleted webhook trigger secrets when restoring a historical revision", async () => {
const { routine, svc } = await seedFixture();
const created = await svc.createTrigger(routine.id, {
kind: "webhook",
signingMode: "bearer",
replayWindowSec: 300,
}, {});
await svc.deleteTrigger(created.trigger.id, {});
const restored = await svc.restoreRevision(routine.id, created.revision.id, {});
expect(restored.secretMaterials).toHaveLength(1);
expect(restored.secretMaterials[0]).toMatchObject({
triggerId: created.trigger.id,
});
expect(restored.secretMaterials[0]?.webhookSecret).toBeTruthy();
expect(restored.secretMaterials[0]?.webhookUrl).toContain("/api/routine-triggers/public/");
const restoredTrigger = await svc.getTrigger(created.trigger.id);
expect(restoredTrigger?.secretId).toBeTruthy();
expect(restoredTrigger?.publicId).toBeTruthy();
expect(restoredTrigger?.publicId).not.toBe(created.trigger.publicId);
});
it("blocks agents from restoring routine revisions assigned to another agent", async () => {
const { companyId, routine, svc } = await seedFixture();
const otherAgentId = randomUUID();
await db.insert(agents).values({
id: otherAgentId,
companyId,
name: "OtherCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const revision1Id = routine.latestRevisionId!;
await svc.update(routine.id, { assigneeAgentId: otherAgentId }, {});
await expect(
svc.restoreRevision(routine.id, revision1Id, { agentId: otherAgentId }),
).rejects.toMatchObject({
status: 403,
message: "Agents can only restore routine revisions assigned to themselves",
});
await expect(svc.get(routine.id)).resolves.toMatchObject({
assigneeAgentId: otherAgentId,
latestRevisionNumber: 2,
});
});
it("blocks restoring routine revisions assigned to agents that are no longer assignable", async () => {
const { agentId, routine, svc } = await seedFixture();
const revision1Id = routine.latestRevisionId!;
await svc.update(routine.id, { description: "revision 2" }, {});
await db
.update(agents)
.set({ status: "terminated" })
.where(eq(agents.id, agentId));
await expect(
svc.restoreRevision(routine.id, revision1Id, { userId: "board-user" }),
).rejects.toMatchObject({
status: 409,
message: "Cannot assign routines to terminated agents",
});
await expect(svc.get(routine.id)).resolves.toMatchObject({
description: "revision 2",
latestRevisionNumber: 2,
});
});
it("appends safe trigger metadata revisions without leaking webhook secrets", async () => {
const { routine, svc } = await seedFixture();
const created = await svc.createTrigger(routine.id, {
kind: "webhook",
signingMode: "bearer",
replayWindowSec: 300,
}, {});
expect(created.revision.revisionNumber).toBe(2);
expect(created.secretMaterial?.webhookSecret).toBeTruthy();
const updated = await svc.updateTrigger(created.trigger.id, { label: "deploy hook" }, {});
expect(updated?.revision.revisionNumber).toBe(3);
const rotated = await svc.rotateTriggerSecret(created.trigger.id, {});
expect(rotated.revision.revisionNumber).toBe(4);
expect(rotated.secretMaterial.webhookSecret).toBeTruthy();
const deleted = await svc.deleteTrigger(created.trigger.id, {});
expect(deleted.revision?.revisionNumber).toBe(5);
const revisions = await svc.listRevisions(routine.id);
const serialized = JSON.stringify(revisions.map((revision) => revision.snapshot));
expect(serialized).toContain(created.trigger.publicId!);
expect(serialized).not.toContain(created.secretMaterial!.webhookSecret);
expect(serialized).not.toContain(rotated.secretMaterial.webhookSecret);
expect(serialized).not.toContain(created.trigger.secretId!);
expect(revisions[0]?.snapshot.triggers).toHaveLength(0);
});
it("wakes the assignee when a routine creates a fresh execution issue", async () => {
const { agentId, routine, svc, wakeups } = await seedFixture();
@@ -1077,6 +1279,82 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(run.linkedIssueId).toBeTruthy();
});
it("uses the configured provider for generated webhook trigger secrets", async () => {
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
const originalGetSecretProvider = providerRegistry.getSecretProvider;
const getSecretProviderSpy = vi.spyOn(providerRegistry, "getSecretProvider").mockImplementation((provider) => {
if (provider !== "aws_secrets_manager") {
return originalGetSecretProvider(provider);
}
return {
id: "aws_secrets_manager",
descriptor: () => ({
id: "aws_secrets_manager",
label: "AWS Secrets Manager",
supportsManaged: true,
supportsExternalReference: true,
}),
validateConfig: async () => ({ ok: true, warnings: [] }),
createSecret: async ({ value }) => ({
material: { source: "managed", secretId: "arn:aws:secretsmanager:stub", versionId: "v1" },
valueSha256: `sha:${value}`,
fingerprintSha256: `sha:${value}`,
externalRef: "arn:aws:secretsmanager:stub",
providerVersionRef: "v1",
}),
createVersion: async ({ value }) => ({
material: { source: "managed", secretId: "arn:aws:secretsmanager:stub", versionId: "v2" },
valueSha256: `sha:${value}`,
fingerprintSha256: `sha:${value}`,
externalRef: "arn:aws:secretsmanager:stub",
providerVersionRef: "v2",
}),
linkExternalSecret: async ({ externalRef, providerVersionRef }) => ({
material: { source: "external", secretId: externalRef, versionId: providerVersionRef ?? null },
valueSha256: "external",
fingerprintSha256: "external",
externalRef,
providerVersionRef: providerVersionRef ?? null,
}),
resolveVersion: async () => "resolved-secret",
deleteOrArchive: async () => undefined,
healthCheck: async () => ({
provider: "aws_secrets_manager",
status: "ok",
message: "stubbed",
}),
};
});
try {
const { routine, svc } = await seedFixture();
const { trigger } = await svc.createTrigger(
routine.id,
{
kind: "webhook",
signingMode: "hmac_sha256",
replayWindowSec: 300,
},
{},
);
const [secret] = await db
.select({
id: companySecrets.id,
provider: companySecrets.provider,
})
.from(companySecrets)
.where(eq(companySecrets.id, trigger.secretId!));
expect(secret).toMatchObject({
id: trigger.secretId,
provider: "aws_secrets_manager",
});
} finally {
getSecretProviderSpy.mockRestore();
}
});
it("accepts GitHub-style X-Hub-Signature-256 with github_hmac signing mode", async () => {
const { routine, svc } = await seedFixture();
const { trigger, secretMaterial } = await svc.createTrigger(
@@ -76,10 +76,12 @@ describe("run liveness continuations", () => {
continuationAttempt: 1,
maxContinuationAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
instruction: "Take the first concrete action now.",
modelProfile: "cheap",
});
expect(decision.contextSnapshot).toMatchObject({
issueId,
wakeReason: RUN_LIVENESS_CONTINUATION_REASON,
modelProfile: "cheap",
livenessContinuationAttempt: 1,
livenessContinuationMaxAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
livenessContinuationSourceRunId: runId,
@@ -0,0 +1,70 @@
import { randomBytes } from "node:crypto";
import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { checkSecretProviders, listSecretProviders } from "../secrets/provider-registry.js";
describe("secret provider registry", () => {
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
const tmpDirs: string[] = [];
afterEach(() => {
if (previousKeyFile === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
}
if (previousMasterKey === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
}
for (const dir of tmpDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("describes managed and external-reference provider capabilities", () => {
const descriptors = listSecretProviders();
expect(descriptors).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "local_encrypted",
supportsManagedValues: true,
supportsExternalReferences: false,
configured: true,
}),
expect.objectContaining({
id: "aws_secrets_manager",
supportsManagedValues: true,
supportsExternalReferences: true,
configured: false,
}),
]),
);
});
it("warns when the local encrypted key file is readable by group or others", async () => {
const dir = path.join(os.tmpdir(), `paperclip-secret-provider-${randomBytes(6).toString("hex")}`);
tmpDirs.push(dir);
mkdirSync(dir, { recursive: true });
const keyFile = path.join(dir, "master.key");
writeFileSync(keyFile, randomBytes(32).toString("base64"), { encoding: "utf8", mode: 0o644 });
chmodSync(keyFile, 0o644);
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = keyFile;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
const checks = await checkSecretProviders();
const local = checks.find((check) => check.provider === "local_encrypted");
expect(local).toMatchObject({
status: "warn",
details: { keyFilePath: keyFile },
});
expect(local?.warnings?.join("\n")).toContain("chmod 600");
expect(local?.backupGuidance?.join("\n")).toContain("database");
});
});
+454
View File
@@ -0,0 +1,454 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { secretRoutes } from "../routes/secrets.js";
import { errorHandler } from "../middleware/error-handler.js";
import { HttpError, unprocessable } from "../errors.js";
const mockSecretService = vi.hoisted(() => ({
listProviders: vi.fn(),
checkProviders: vi.fn(),
listProviderConfigs: vi.fn(),
getProviderConfigById: vi.fn(),
createProviderConfig: vi.fn(),
updateProviderConfig: vi.fn(),
disableProviderConfig: vi.fn(),
setDefaultProviderConfig: vi.fn(),
checkProviderConfigHealth: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
previewRemoteImport: vi.fn(),
importRemoteSecrets: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
secretService: () => mockSecretService,
logActivity: mockLogActivity,
}));
function createApp(actor: Record<string, unknown> = {
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
}) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", secretRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("secret routes", () => {
beforeEach(() => {
for (const mock of Object.values(mockSecretService)) {
mock.mockReset();
}
mockLogActivity.mockReset();
});
it("returns provider health checks for board callers with company access", async () => {
mockSecretService.checkProviders.mockResolvedValue([
{
provider: "local_encrypted",
status: "ok",
message: "Local encrypted provider configured",
backupGuidance: ["Back up the key file together with database backups."],
},
]);
const res = await request(createApp()).get("/api/companies/company-1/secret-providers/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({
providers: [
{
provider: "local_encrypted",
status: "ok",
message: "Local encrypted provider configured",
backupGuidance: ["Back up the key file together with database backups."],
},
],
});
});
it("rejects managed secret creation when externalRef is supplied", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secrets").send({
name: "OpenAI API Key",
managedMode: "paperclip_managed",
value: "secret-value",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other",
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/Managed secrets cannot set externalRef/);
expect(mockSecretService.create).not.toHaveBeenCalled();
});
it("rejects provider vault routes for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
})).get("/api/companies/company-1/secret-provider-configs");
expect(res.status).toBe(403);
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
});
it("rejects provider vault cross-company access before calling the service", async () => {
const res = await request(createApp({
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-2"],
memberships: [{ companyId: "company-2", status: "active", membershipRole: "admin" }],
})).get("/api/companies/company-1/secret-provider-configs");
expect(res.status).toBe(403);
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
});
it("rejects sensitive provider vault config fields", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "aws_secrets_manager",
displayName: "AWS prod",
config: {
region: "us-east-1",
accessKeyId: "AKIA...",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/sensitive field/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it("rejects ready status for coming-soon provider vaults", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "vault",
displayName: "Vault draft",
status: "ready",
config: {
address: "https://vault.example.com",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/locked while coming soon/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it("rejects credential-bearing Vault provider vault addresses before persistence", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "vault",
displayName: "Vault draft",
config: {
address: "https://user:pass@vault.example.com",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it.each([
"https://vault.example.com?token=hvs.x",
"https://vault.example.com#token=hvs.x",
])("rejects token-bearing Vault provider vault address %s before persistence", async (address) => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "vault",
displayName: "Vault draft",
config: { address },
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it("rejects unsafe Vault provider vault address patches before persistence", async () => {
const res = await request(createApp()).patch("/api/secret-provider-configs/vault-1").send({
config: {
address: "https://vault.example.com#token=hvs.x",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
expect(mockSecretService.getProviderConfigById).not.toHaveBeenCalled();
expect(mockSecretService.updateProviderConfig).not.toHaveBeenCalled();
});
it("creates provider vaults and logs safe activity details", async () => {
const createdAt = new Date("2026-05-06T00:00:00.000Z");
mockSecretService.createProviderConfig.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
provider: "aws_secrets_manager",
displayName: "AWS prod",
status: "ready",
isDefault: true,
config: { region: "us-east-1" },
healthStatus: null,
healthCheckedAt: null,
healthMessage: null,
healthDetails: null,
disabledAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
createdAt,
updatedAt: createdAt,
});
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "aws_secrets_manager",
displayName: "AWS prod",
isDefault: true,
config: { region: "us-east-1" },
});
expect(res.status).toBe(201);
expect(mockSecretService.createProviderConfig).toHaveBeenCalledWith(
"company-1",
{
provider: "aws_secrets_manager",
displayName: "AWS prod",
status: undefined,
isDefault: true,
config: { region: "us-east-1" },
},
{ userId: "user-1", agentId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret_provider_config.created",
details: {
provider: "aws_secrets_manager",
displayName: "AWS prod",
status: "ready",
isDefault: true,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey");
});
it("rejects remote import preview for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
})).post("/api/companies/company-1/secrets/remote-import/preview").send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
});
expect(res.status).toBe(403);
expect(mockSecretService.previewRemoteImport).not.toHaveBeenCalled();
});
it("previews remote imports and logs only aggregate metadata", async () => {
mockSecretService.previewRemoteImport.mockResolvedValue({
providerConfigId: "11111111-1111-4111-8111-111111111111",
provider: "aws_secrets_manager",
nextToken: null,
candidates: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
remoteName: "prod/openai",
name: "openai",
key: "openai",
providerVersionRef: null,
providerMetadata: { description: "OpenAI API key" },
status: "ready",
importable: true,
conflicts: [],
},
],
});
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import/preview")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
pageSize: 25,
});
expect(res.status).toBe(200);
expect(mockSecretService.previewRemoteImport).toHaveBeenCalledWith("company-1", {
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
nextToken: undefined,
pageSize: 25,
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret.remote_import.previewed",
details: {
provider: "aws_secrets_manager",
candidateCount: 1,
readyCount: 1,
duplicateCount: 0,
conflictCount: 0,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
});
it("returns sanitized remote import preview provider errors", async () => {
mockSecretService.previewRemoteImport.mockRejectedValue(
new HttpError(
403,
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
{ code: "access_denied" },
),
);
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import/preview")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
});
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: { code: "access_denied" },
});
expect(JSON.stringify(res.body)).not.toContain("arn:aws");
expect(JSON.stringify(res.body)).not.toContain("123456789012");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("imports remote references and logs aggregate row counts", async () => {
mockSecretService.importRemoteSecrets.mockResolvedValue({
providerConfigId: "11111111-1111-4111-8111-111111111111",
provider: "aws_secrets_manager",
importedCount: 1,
skippedCount: 0,
errorCount: 0,
results: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
status: "imported",
reason: null,
secretId: "22222222-2222-4222-8222-222222222222",
conflicts: [],
},
],
});
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
description: "Operator-entered Paperclip description",
},
],
});
expect(res.status).toBe(200);
expect(mockSecretService.importRemoteSecrets).toHaveBeenCalledWith(
"company-1",
{
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
description: "Operator-entered Paperclip description",
},
],
},
{ userId: "user-1", agentId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret.remote_import.completed",
details: {
provider: "aws_secrets_manager",
importedCount: 1,
skippedCount: 0,
errorCount: 0,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
});
it("surfaces update-route externalRef retarget rejection without logging raw refs", async () => {
mockSecretService.getById.mockResolvedValue({
id: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
name: "OpenAI API key",
key: "openai-api-key",
provider: "aws_secrets_manager",
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
});
mockSecretService.update.mockRejectedValue(
unprocessable("External reference secrets cannot be retargeted through generic update"),
);
const res = await request(createApp())
.patch("/api/secrets/22222222-2222-4222-8222-222222222222")
.send({
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
});
expect(res.status).toBe(422);
expect(mockSecretService.update).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
}),
);
expect(mockLogActivity).not.toHaveBeenCalled();
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("shared/repointed");
});
it("allows DELETE to retry cleanup for already soft-deleted secrets", async () => {
const secret = {
id: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
name: "OpenAI API Key__deleted__33333333-3333-4333-8333-333333333333",
key: "openai-api-key__deleted__33333333-3333-4333-8333-333333333333",
provider: "aws_secrets_manager",
managedMode: "paperclip_managed",
status: "deleted",
};
mockSecretService.getById.mockResolvedValue(secret);
mockSecretService.remove.mockResolvedValue(secret);
const res = await request(createApp()).delete(
"/api/secrets/33333333-3333-4333-8333-333333333333",
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(mockSecretService.remove).toHaveBeenCalledWith(
"33333333-3333-4333-8333-333333333333",
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "secret.deleted",
companyId: "company-1",
entityId: secret.id,
}),
);
});
});
File diff suppressed because it is too large Load Diff
@@ -146,6 +146,7 @@ vi.mock("../services/index.js", () => ({
reconcileStrandedAssignedIssues: vi.fn(async () => ({
dispatchRequeued: 0,
continuationRequeued: 0,
successfulRunHandoffEscalated: 0,
escalated: 0,
skipped: 0,
issueIds: [],
@@ -3134,6 +3134,130 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
expect(persisted?.healthStatus).toBe("unknown");
expect(persisted?.stoppedAt).toBeTruthy();
});
it("restarts a stopped auto-port service on the same port when it is available", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-port-reuse-"));
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Runtime port reuse test",
status: "active",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Execution workspace port reuse test",
status: "active",
cwd: workspaceRoot,
providerType: "local_fs",
providerRef: workspaceRoot,
});
const actor = {
id: agentId,
name: "Codex Coder",
companyId,
};
const workspace = {
...buildWorkspace(workspaceRoot),
projectId,
workspaceId: null,
};
const config = {
workspaceRuntime: {
services: [
{
name: "web",
command:
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"",
port: { type: "auto" },
readiness: {
type: "http",
urlTemplate: "http://127.0.0.1:{{port}}",
timeoutSec: 10,
intervalMs: 100,
},
expose: {
type: "url",
urlTemplate: "http://127.0.0.1:{{port}}",
},
lifecycle: "shared",
reuseScope: "execution_workspace",
stopPolicy: {
type: "manual",
},
},
],
},
};
const first = await startRuntimeServicesForWorkspaceControl({
db,
actor,
issue: null,
workspace,
executionWorkspaceId,
config,
adapterEnv: {},
});
expect(first).toHaveLength(1);
expect(first[0]?.port).toBeGreaterThan(0);
await expect(fetch(first[0]!.url!)).resolves.toMatchObject({ ok: true });
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId,
workspaceCwd: workspace.cwd,
});
await expect(fetch(first[0]!.url!)).rejects.toThrow();
const second = await startRuntimeServicesForWorkspaceControl({
db,
actor,
issue: null,
workspace,
executionWorkspaceId,
config,
adapterEnv: {},
});
expect(second).toHaveLength(1);
expect(second[0]?.id).toBe(first[0]?.id);
expect(second[0]?.port).toBe(first[0]?.port);
expect(second[0]?.url).toBe(first[0]?.url);
await expect(fetch(second[0]!.url!)).resolves.toMatchObject({ ok: true });
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId,
workspaceCwd: workspace.cwd,
});
});
});
describe("normalizeAdapterManagedRuntimeServices", () => {
@@ -5,6 +5,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([
"acpx_local",
"claude_local",
"codex_local",
"cursor_cloud",
"cursor",
"gemini_local",
"openclaw_gateway",
+122 -3
View File
@@ -1,5 +1,13 @@
import type { AdapterModelProfileDefinition, ServerAdapterModule } from "./types.js";
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
import type {
AdapterModel,
AdapterModelProfileDefinition,
AdapterRuntimeCommandSpec,
ServerAdapterModule,
} from "./types.js";
import {
buildSandboxNpmInstallCommand,
getAdapterSessionManagement,
} from "@paperclipai/adapter-utils";
import {
execute as acpxExecute,
testEnvironment as acpxTestEnvironment,
@@ -8,7 +16,10 @@ import {
listAcpxSkills,
syncAcpxSkills,
} from "@paperclipai/adapter-acpx-local/server";
import { agentConfigurationDoc as acpxAgentConfigurationDoc } from "@paperclipai/adapter-acpx-local";
import {
agentConfigurationDoc as acpxAgentConfigurationDoc,
models as acpxModels,
} from "@paperclipai/adapter-acpx-local";
import {
execute as claudeExecute,
listClaudeSkills,
@@ -48,6 +59,13 @@ import {
models as cursorModels,
modelProfiles as cursorModelProfiles,
} from "@paperclipai/adapter-cursor-local";
import {
execute as cursorCloudExecute,
getConfigSchema as getCursorCloudConfigSchema,
sessionCodec as cursorCloudSessionCodec,
testEnvironment as cursorCloudTestEnvironment,
} from "@paperclipai/adapter-cursor-cloud/server";
import { agentConfigurationDoc as cursorCloudAgentConfigurationDoc } from "@paperclipai/adapter-cursor-cloud";
import {
execute as geminiExecute,
listGeminiSkills,
@@ -113,6 +131,45 @@ import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js";
import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js";
function readConfiguredCommand(config: Record<string, unknown>, fallback: string): string {
const value = typeof config.command === "string" ? config.command.trim() : "";
return value.length > 0 ? value : fallback;
}
function hasPathSeparator(command: string): boolean {
return command.includes("/") || command.includes("\\");
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function buildNpmRuntimeCommandSpec(
config: Record<string, unknown>,
fallbackCommand: string,
packageName: string,
): AdapterRuntimeCommandSpec {
const command = readConfiguredCommand(config, fallbackCommand);
const canSelfInstall = !hasPathSeparator(command) && command === fallbackCommand;
const installLine = buildSandboxNpmInstallCommand(packageName);
return {
command,
detectCommand: command,
installCommand: canSelfInstall
? `if ! command -v ${shellQuote(command)} >/dev/null 2>&1; then ${installLine}; fi`
: null,
};
}
function buildCursorRuntimeCommandSpec(config: Record<string, unknown>): AdapterRuntimeCommandSpec {
const command = readConfiguredCommand(config, "agent");
return {
command,
detectCommand: command,
installCommand: null,
};
}
function normalizeHermesConfig<T extends { config?: unknown; agent?: unknown }>(ctx: T): T {
const config =
ctx && typeof ctx === "object" && "config" in ctx && ctx.config && typeof ctx.config === "object"
@@ -144,6 +201,38 @@ function normalizeHermesConfig<T extends { config?: unknown; agent?: unknown }>(
return ctx;
}
function dedupeAdapterModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const result: AdapterModel[] = [];
for (const model of models) {
const id = model.id.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
result.push({ ...model, id });
}
return result;
}
function prefixAdapterModelLabels(models: AdapterModel[], provider: "Claude" | "Codex"): AdapterModel[] {
const prefix = `${provider}: `;
return models.map((model) => ({
...model,
label: model.label.startsWith(prefix) ? model.label : `${prefix}${model.label}`,
}));
}
async function listAcpxModels(): Promise<AdapterModel[]> {
const [claude, codex] = await Promise.all([
listClaudeModels().catch(() => claudeModels),
listCodexModels().catch(() => codexModels),
]);
return dedupeAdapterModels([
...acpxModels,
...prefixAdapterModelLabels(claude, "Claude"),
...prefixAdapterModelLabels(codex, "Codex"),
]);
}
const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
execute: claudeExecute,
@@ -159,6 +248,8 @@ const claudeLocalAdapter: ServerAdapterModule = {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
getRuntimeCommandSpec: (config) =>
buildNpmRuntimeCommandSpec(config, "claude", "@anthropic-ai/claude-code"),
agentConfigurationDoc: claudeAgentConfigurationDoc,
getQuotaWindows: claudeGetQuotaWindows,
};
@@ -171,6 +262,11 @@ const acpxLocalAdapter: ServerAdapterModule = {
syncSkills: syncAcpxSkills,
sessionCodec: acpxSessionCodec,
sessionManagement: getAdapterSessionManagement("acpx_local") ?? undefined,
models: dedupeAdapterModels([
...prefixAdapterModelLabels(claudeModels, "Claude"),
...prefixAdapterModelLabels(codexModels, "Codex"),
]),
listModels: listAcpxModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
@@ -195,6 +291,7 @@ const codexLocalAdapter: ServerAdapterModule = {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
agentConfigurationDoc: codexAgentConfigurationDoc,
getQuotaWindows: codexGetQuotaWindows,
};
@@ -214,9 +311,25 @@ const cursorLocalAdapter: ServerAdapterModule = {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
getRuntimeCommandSpec: buildCursorRuntimeCommandSpec,
agentConfigurationDoc: cursorAgentConfigurationDoc,
};
const cursorCloudAdapter: ServerAdapterModule = {
type: "cursor_cloud",
execute: cursorCloudExecute,
testEnvironment: cursorCloudTestEnvironment,
sessionCodec: cursorCloudSessionCodec,
sessionManagement: getAdapterSessionManagement("cursor_cloud") ?? undefined,
models: [],
supportsLocalAgentJwt: false,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
agentConfigurationDoc: cursorCloudAgentConfigurationDoc,
getConfigSchema: getCursorCloudConfigSchema,
};
const geminiLocalAdapter: ServerAdapterModule = {
type: "gemini_local",
execute: geminiExecute,
@@ -231,6 +344,8 @@ const geminiLocalAdapter: ServerAdapterModule = {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
getRuntimeCommandSpec: (config) =>
buildNpmRuntimeCommandSpec(config, "gemini", "@google/gemini-cli"),
agentConfigurationDoc: geminiAgentConfigurationDoc,
};
@@ -260,6 +375,7 @@ const openCodeLocalAdapter: ServerAdapterModule = {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai"),
agentConfigurationDoc: openCodeAgentConfigurationDoc,
};
@@ -278,6 +394,8 @@ const piLocalAdapter: ServerAdapterModule = {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
getRuntimeCommandSpec: (config) =>
buildNpmRuntimeCommandSpec(config, "pi", "@mariozechner/pi-coding-agent"),
agentConfigurationDoc: piAgentConfigurationDoc,
};
@@ -365,6 +483,7 @@ function registerBuiltInAdapters() {
codexLocalAdapter,
openCodeLocalAdapter,
piLocalAdapter,
cursorCloudAdapter,
cursorLocalAdapter,
geminiLocalAdapter,
openclawGatewayAdapter,
+1
View File
@@ -30,5 +30,6 @@ export type {
ConfigFieldOption,
ConfigFieldSchema,
AdapterConfigSchema,
AdapterRuntimeCommandSpec,
ServerAdapterModule,
} from "@paperclipai/adapter-utils";
+3
View File
@@ -253,6 +253,8 @@ export async function createApp(
instanceInfo: {
instanceId: opts.instanceId ?? "default",
hostVersion: opts.hostVersion ?? "0.0.0",
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
},
buildHostHandlers: (pluginId, manifest) => {
const notifyWorker = (method: string, params: unknown) => {
@@ -261,6 +263,7 @@ export async function createApp(
};
const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker, {
pluginWorkerManager: workerManager,
manifest,
});
hostServicesDisposers.set(pluginId, () => services.dispose());
return createHostClientHandlers({
+5 -5
View File
@@ -120,11 +120,6 @@ export function loadConfig(): Config {
const fileDatabaseBackup = fileConfig?.database.backup;
const fileSecrets = fileConfig?.secrets;
const fileStorage = fileConfig?.storage;
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
const secretsStrictMode =
strictModeFromEnv !== undefined
? strictModeFromEnv === "true"
: (fileSecrets?.strictMode ?? false);
const providerFromEnvRaw = process.env.PAPERCLIP_SECRETS_PROVIDER;
const providerFromEnv =
@@ -168,6 +163,11 @@ export function loadConfig(): Config {
? (deploymentModeFromEnvRaw as DeploymentMode)
: null;
const deploymentMode: DeploymentMode = deploymentModeFromEnv ?? fileConfig?.server.deploymentMode ?? "local_trusted";
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
const secretsStrictMode =
strictModeFromEnv !== undefined
? strictModeFromEnv === "true"
: (fileSecrets?.strictMode ?? deploymentMode === "authenticated");
const deploymentExposureFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
const deploymentExposureFromEnv =
deploymentExposureFromEnvRaw &&
+26 -37
View File
@@ -1,57 +1,50 @@
import os from "node:os";
import path from "node:path";
const DEFAULT_INSTANCE_ID = "default";
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
const FRIENDLY_PATH_SEGMENT_RE = /[^a-zA-Z0-9._-]+/g;
import {
expandHomePrefix,
resolveDefaultBackupDir as resolveSharedDefaultBackupDir,
resolveDefaultEmbeddedPostgresDir as resolveSharedDefaultEmbeddedPostgresDir,
resolveDefaultLogsDir as resolveSharedDefaultLogsDir,
resolveDefaultSecretsKeyFilePath as resolveSharedDefaultSecretsKeyFilePath,
resolveDefaultStorageDir as resolveSharedDefaultStorageDir,
resolveHomeAwarePath,
resolvePaperclipConfigPathForInstance,
resolvePaperclipHomeDir,
resolvePaperclipInstanceId,
resolvePaperclipInstanceRoot,
} from "@paperclipai/shared/home-paths";
function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
export function resolvePaperclipHomeDir(): string {
const envHome = process.env.PAPERCLIP_HOME?.trim();
if (envHome) return path.resolve(expandHomePrefix(envHome));
return path.resolve(os.homedir(), ".paperclip");
}
export function resolvePaperclipInstanceId(): string {
const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
if (!INSTANCE_ID_RE.test(raw)) {
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
}
return raw;
}
export function resolvePaperclipInstanceRoot(): string {
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId());
}
export {
expandHomePrefix,
resolveHomeAwarePath,
resolvePaperclipHomeDir,
resolvePaperclipInstanceId,
resolvePaperclipInstanceRoot,
};
export function resolveDefaultConfigPath(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "config.json");
return resolvePaperclipConfigPathForInstance();
}
export function resolveDefaultEmbeddedPostgresDir(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "db");
return resolveSharedDefaultEmbeddedPostgresDir();
}
export function resolveDefaultLogsDir(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "logs");
return resolveSharedDefaultLogsDir();
}
export function resolveDefaultSecretsKeyFilePath(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "master.key");
return resolveSharedDefaultSecretsKeyFilePath();
}
export function resolveDefaultStorageDir(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "data", "storage");
return resolveSharedDefaultStorageDir();
}
export function resolveDefaultBackupDir(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "data", "backups");
return resolveSharedDefaultBackupDir();
}
export function resolveDefaultAgentWorkspaceDir(agentId: string): string {
@@ -89,7 +82,3 @@ export function resolveManagedProjectWorkspaceDir(input: {
sanitizeFriendlyPathSegment(input.repoName, "_default"),
);
}
export function resolveHomeAwarePath(value: string): string {
return path.resolve(expandHomePrefix(value));
}
+2
View File
@@ -686,6 +686,7 @@ export async function startServer(): Promise<StartedServer> {
reconciled.assignmentDispatched > 0 ||
reconciled.dispatchRequeued > 0 ||
reconciled.continuationRequeued > 0 ||
reconciled.successfulRunHandoffEscalated > 0 ||
reconciled.escalated > 0
) {
logger.warn(
@@ -751,6 +752,7 @@ export async function startServer(): Promise<StartedServer> {
reconciled.assignmentDispatched > 0 ||
reconciled.dispatchRequeued > 0 ||
reconciled.continuationRequeued > 0 ||
reconciled.successfulRunHandoffEscalated > 0 ||
reconciled.escalated > 0
) {
logger.warn(
+155 -2
View File
@@ -1,8 +1,8 @@
import { createHash } from "node:crypto";
import { createHash, timingSafeEqual } from "node:crypto";
import type { Request, RequestHandler } from "express";
import { and, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agentApiKeys, agents, companyMemberships, instanceUserRoles } from "@paperclipai/db";
import { agentApiKeys, agents, authUsers, companies, companyMemberships, instanceUserRoles } from "@paperclipai/db";
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
import type { DeploymentMode } from "@paperclipai/shared";
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
@@ -38,6 +38,16 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
const authHeader = req.header("authorization");
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
if (opts.deploymentMode === "authenticated" && opts.resolveSession) {
const cloudTenantActor = await resolveCloudTenantActor(db, req);
if (cloudTenantActor) {
req.actor = {
...cloudTenantActor,
runId: runIdHeader ?? undefined,
};
next();
return;
}
let session: BetterAuthSessionResult | null = null;
try {
session = await opts.resolveSession(req);
@@ -189,6 +199,149 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
};
}
async function resolveCloudTenantActor(db: Db, req: Request): Promise<Express.Request["actor"] | null> {
const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim();
if (!expectedToken) return null;
const token = req.header("x-paperclip-cloud-tenant-token")?.trim();
if (!token || !constantTimeStringEqual(token, expectedToken)) return null;
const userId = requiredCloudHeader(req, "x-paperclip-cloud-user-id");
const userEmail = requiredCloudHeader(req, "x-paperclip-cloud-user-email").toLowerCase();
const stackId = requiredCloudHeader(req, "x-paperclip-cloud-stack-id");
const stackRole = stackMembershipRole(req.header("x-paperclip-cloud-stack-role"));
const userName = req.header("x-paperclip-cloud-user-name")?.trim() || userEmail;
const paperclipCompanyId = req.header("x-paperclip-cloud-paperclip-company-id")?.trim();
const companyId = cloudTenantCompanyId(stackId);
const companyName = paperclipCompanyId || `${stackId} Paperclip`;
const now = new Date();
await db
.insert(authUsers)
.values({
id: userId,
name: userName,
email: userEmail,
emailVerified: true,
image: null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: authUsers.id,
set: {
name: userName,
email: userEmail,
emailVerified: true,
updatedAt: now,
},
});
await db
.insert(instanceUserRoles)
.values({
userId,
role: "instance_admin",
updatedAt: now,
})
.onConflictDoNothing({
target: [instanceUserRoles.userId, instanceUserRoles.role],
});
await db
.insert(companies)
.values({
id: companyId,
name: companyName,
description: `Provisioned by Paperclip Cloud for stack ${stackId}.`,
status: "active",
issuePrefix: issuePrefixForCloudStack(stackId),
updatedAt: now,
})
.onConflictDoNothing({
target: companies.id,
});
const membershipRole = stackRole === "owner" || stackRole === "admin" ? "owner" : stackRole;
const membership = await db
.insert(companyMemberships)
.values({
companyId,
principalType: "user",
principalId: userId,
status: "active",
membershipRole,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
companyMemberships.companyId,
companyMemberships.principalType,
companyMemberships.principalId,
],
set: {
status: "active",
membershipRole,
updatedAt: now,
},
})
.returning()
.then((rows) => rows[0] ?? {
companyId,
membershipRole,
status: "active",
});
return {
type: "board",
userId,
userName,
userEmail,
companyIds: [companyId],
memberships: [{
companyId,
membershipRole: membership.membershipRole,
status: membership.status,
}],
isInstanceAdmin: true,
source: "cloud_tenant",
};
}
function requiredCloudHeader(req: Request, name: string): string {
const value = req.header(name)?.trim();
if (!value) {
throw new Error(`Missing trusted Cloud tenant header ${name}`);
}
return value;
}
function stackMembershipRole(value: string | undefined): "owner" | "admin" | "member" | "support" {
if (value === "owner" || value === "admin" || value === "member" || value === "support") {
return value;
}
throw new Error("Invalid trusted Cloud tenant stack role");
}
function constantTimeStringEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
function cloudTenantCompanyId(stackId: string): string {
const bytes = createHash("sha256").update(`paperclip-cloud-tenant-company:${stackId}`).digest();
bytes[6] = (bytes[6] & 0x0f) | 0x50;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = bytes.subarray(0, 16).toString("hex");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
function issuePrefixForCloudStack(stackId: string): string {
const hash = createHash("sha256").update(stackId).digest("hex").slice(0, 4).toUpperCase();
return `PC${hash}`;
}
export function requireBoard(req: Express.Request) {
return req.actor.type === "board";
}
@@ -4,7 +4,9 @@ You are an agent at Paperclip company.
- Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning.
- Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them.
- Leave durable progress in task comments, documents, or work products, and make the next action clear before you exit.
- Leave durable progress in task comments, documents, or work products, then update the issue to a clear final disposition before you exit.
- Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.
- Final disposition checklist: mark `done` when complete and verified; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists.
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
- Create child issues directly when you know what needs to be done. If the board/user needs to choose suggested tasks, answer structured questions, or confirm a proposal first, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"`.
- Use `request_confirmation` instead of asking for yes/no decisions in markdown. For plan approval, update the `plan` document first, create a confirmation bound to the latest plan revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before creating implementation subtasks.
+4 -2
View File
@@ -1,6 +1,7 @@
import { Router } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { normalizeIssueIdentifier } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { activityService, normalizeActivityLimit } from "../services/activity.js";
import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js";
@@ -24,8 +25,9 @@ export function activityRoutes(db: Db) {
const issueSvc = issueService(db);
async function resolveIssueByRef(rawId: string) {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
return issueSvc.getByIdentifier(rawId);
const identifier = normalizeIssueIdentifier(rawId);
if (identifier) {
return issueSvc.getByIdentifier(identifier);
}
return issueSvc.getById(rawId);
}
+331 -73
View File
@@ -13,6 +13,7 @@ import {
createAgentSchema,
deriveAgentUrlKey,
isUuidLike,
normalizeIssueIdentifier,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
type AgentSkillSnapshot,
@@ -55,8 +56,12 @@ import {
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import { secretService } from "../services/secrets.js";
import {
detectAdapterModel,
@@ -84,7 +89,8 @@ import {
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
import {
loadDefaultAgentInstructionsBundle,
resolveDefaultAgentInstructionsBundleRole,
@@ -158,6 +164,9 @@ export function agentRoutes(
const approvalsSvc = approvalService(db);
const budgets = budgetService(db);
const environmentsSvc = environmentService(db);
const environmentRuntime = environmentRuntimeService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
@@ -189,9 +198,13 @@ export function agentRoutes(
* - SSH environment builds an SSH execution target from the environment
* config so the adapter probes the remote box. No lease is required:
* the SSH spec is fully derived from the saved environment config.
* - Sandbox / plugin environments currently fall back to local probing
* with a warning check, since lifting a temporary sandbox lease for an
* ad-hoc test invocation is out of scope for this iteration.
* - Sandbox / plugin environments acquires an ad-hoc lease, realizes the
* workspace, and resolves a sandbox execution target wired to the runtime
* so the adapter probe runs inside the sandbox the same way a heartbeat
* would. The returned `release` callback rolls the lease back when the
* route is done.
*
* The caller MUST always invoke `release()` (typically in a `finally` block).
*/
async function resolveAdapterTestExecutionContext(input: {
companyId: string;
@@ -201,9 +214,17 @@ export function agentRoutes(
executionTarget: AdapterExecutionTarget | null;
environmentName: string | null;
fallbackChecks: AdapterEnvironmentCheck[];
release: (status?: "released" | "failed") => Promise<void>;
}> {
const noopRelease = async () => {};
if (!input.environmentId) {
return { executionTarget: null, environmentName: null, fallbackChecks: [] };
return {
executionTarget: null,
environmentName: null,
fallbackChecks: [],
release: noopRelease,
};
}
const environment = await environmentsSvc.getById(input.environmentId);
@@ -215,14 +236,20 @@ export function agentRoutes(
{
code: "environment_not_found",
level: "warn",
message: "Selected environment was not found. Falling back to a local probe.",
message: "Selected environment was not found. The test did not run.",
},
],
release: noopRelease,
};
}
if (environment.driver === "local") {
return { executionTarget: null, environmentName: environment.name, fallbackChecks: [] };
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [],
release: noopRelease,
};
}
if (environment.driver === "ssh") {
@@ -239,7 +266,12 @@ export function agentRoutes(
leaseMetadata: null,
});
if (target) {
return { executionTarget: target, environmentName: environment.name, fallbackChecks: [] };
return {
executionTarget: target,
environmentName: environment.name,
fallbackChecks: [],
release: noopRelease,
};
}
return {
executionTarget: null,
@@ -249,9 +281,10 @@ export function agentRoutes(
code: "environment_target_unavailable",
level: "warn",
message:
`Could not resolve an execution target for environment "${environment.name}". Falling back to a local probe.`,
`Could not resolve an execution target for environment "${environment.name}". The test did not run.`,
},
],
release: noopRelease,
};
} catch (err) {
return {
@@ -262,27 +295,163 @@ export function agentRoutes(
code: "environment_target_failed",
level: "warn",
message:
`Could not connect to environment "${environment.name}" to run the test. Falling back to a local probe.`,
`Could not connect to environment "${environment.name}" to run the test.`,
detail: err instanceof Error ? err.message : String(err),
},
],
release: noopRelease,
};
}
}
// sandbox / plugin / other drivers: not yet supported for ad-hoc adapter tests.
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_driver_not_supported_for_test",
level: "warn",
message:
`Adapter testing inside ${environment.driver} environments is not yet supported. Falling back to a local probe; results may not reflect runs in "${environment.name}".`,
hint: "Run a real heartbeat in the environment to verify end-to-end behavior.",
// sandbox / plugin / other remote drivers: spin up an ad-hoc lease, realize
// the workspace inside the box, and run the same probe SSH uses against
// a sandbox execution target wired to the environment runtime.
//
// We pass `heartbeatRunId: null` because there's no heartbeat run for an
// operator-initiated `Test` invocation — the leases table FKs heartbeat
// run id to heartbeat_runs.id, and we don't want to manufacture a fake
// run row. Cleanup goes through the driver's `releaseRunLease` directly
// (by lease record), since the batch helper queries by heartbeatRunId.
let leaseRecord: Awaited<ReturnType<typeof environmentRuntime.acquireRunLease>>;
try {
leaseRecord = await environmentRuntime.acquireRunLease({
companyId: input.companyId,
environment,
issueId: null,
heartbeatRunId: null,
persistedExecutionWorkspace: null,
});
} catch (err) {
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_lease_acquire_failed",
level: "error",
message: `Could not acquire a lease for environment "${environment.name}".`,
detail: err instanceof Error ? err.message : String(err),
hint: "Check the environment's provider credentials and quota.",
},
],
release: noopRelease,
};
}
const driver = environmentRuntime.getDriver(environment.driver);
const releaseLease = async (status: "released" | "failed" = "released") => {
try {
if (driver) {
await driver.releaseRunLease({
environment,
lease: leaseRecord.lease,
status,
});
} else {
await environmentsSvc.releaseLease(leaseRecord.lease.id, status);
}
} catch (err) {
// Cleanup failures must not mask the test result.
// eslint-disable-next-line no-console
console.warn(
`[adapter-test] Failed to release lease ${leaseRecord.lease.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
};
let realizedCwd: string | null = null;
try {
const realized = await environmentRuntime.realizeWorkspace({
environment,
lease: leaseRecord.lease,
// No host workspace to copy for a Test invocation; sandbox/plugin
// realize implementations use the lease metadata's remoteCwd to
// create the working directory inside the box.
workspace: {},
});
realizedCwd =
typeof realized.cwd === "string" && realized.cwd.trim().length > 0
? realized.cwd.trim()
: null;
} catch (err) {
await releaseLease("failed");
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_workspace_realize_failed",
level: "error",
message: `Could not realize a workspace inside "${environment.name}".`,
detail: err instanceof Error ? err.message : String(err),
},
],
release: noopRelease,
};
}
let target: AdapterExecutionTarget | null;
try {
// Prefer the cwd the realize step returned; fall back to lease metadata.
const leaseMetadataForTarget: Record<string, unknown> | null =
realizedCwd
? { ...(leaseRecord.lease.metadata ?? {}), remoteCwd: realizedCwd }
: (leaseRecord.lease.metadata as Record<string, unknown> | null) ?? null;
target = await resolveEnvironmentExecutionTarget({
db,
companyId: input.companyId,
adapterType: input.adapterType,
environment: {
id: environment.id,
driver: environment.driver,
config: environment.config ?? null,
},
],
leaseId: leaseRecord.lease.id,
leaseMetadata: leaseMetadataForTarget,
lease: leaseRecord.lease,
environmentRuntime,
});
} catch (err) {
await releaseLease("failed");
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_failed",
level: "error",
message: `Could not resolve a sandbox execution target for "${environment.name}".`,
detail: err instanceof Error ? err.message : String(err),
},
],
release: noopRelease,
};
}
if (!target) {
await releaseLease("failed");
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_unsupported",
level: "warn",
message:
`Adapter "${input.adapterType}" is not allowed in "${environment.name}" environments.`,
},
],
release: noopRelease,
};
}
return {
executionTarget: target,
environmentName: environment.name,
fallbackChecks: [],
release: releaseLease,
};
}
@@ -767,7 +936,6 @@ export function agentRoutes(
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
input.companyId,
input.adapterType,
input.constraintAdapterConfig
? { ...input.constraintAdapterConfig, ...normalizedAdapterConfig }
@@ -864,7 +1032,10 @@ export function agentRoutes(
next.model = DEFAULT_GEMINI_LOCAL_MODEL;
return ensureGatewayDeviceKey(adapterType, next);
}
// OpenCode requires explicit model selection — no default
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
return ensureGatewayDeviceKey(adapterType, next);
}
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
}
@@ -872,20 +1043,12 @@ export function agentRoutes(
}
async function assertAdapterConfigConstraints(
companyId: string,
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
) {
if (adapterType !== "opencode_local") return;
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: runtimeConfig.model,
command: runtimeConfig.command,
cwd: runtimeConfig.cwd,
env: runtimeEnv,
});
requireOpenCodeModelId(adapterConfig.model);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
@@ -1194,6 +1357,17 @@ export function agentRoutes(
const refresh = typeof req.query.refresh === "string"
? ["1", "true", "yes"].includes(req.query.refresh.toLowerCase())
: false;
const environmentId = asNonEmptyString(req.query.environmentId);
const environment = environmentId ? await environmentsSvc.getById(environmentId) : null;
if (environmentId && (!environment || environment.companyId !== companyId)) {
res.status(404).json({ error: "Environment not found" });
return;
}
if (type === "opencode_local" && environment && environment.driver !== "local") {
const adapter = requireServerAdapter(type);
res.json(adapter.models ?? []);
return;
}
const models = refresh
? await refreshAdapterModels(type)
: await listAdapterModels(type);
@@ -1243,33 +1417,51 @@ export function agentRoutes(
normalizedAdapterConfig,
);
const { executionTarget, environmentName, fallbackChecks } =
const { executionTarget, environmentName, fallbackChecks, release } =
await resolveAdapterTestExecutionContext({
companyId,
adapterType: type,
environmentId: requestedEnvironmentId,
});
const result = await adapter.testEnvironment({
companyId,
adapterType: type,
config: runtimeAdapterConfig,
executionTarget,
environmentName,
});
let releaseStatus: "released" | "failed" = "released";
try {
// If the caller explicitly selected an environment, never fall back to
// probing the host when we couldn't resolve that environment's
// execution target. Surface the diagnostic checks instead.
if (requestedEnvironmentId && !executionTarget && fallbackChecks.length > 0) {
const status: AdapterEnvironmentTestResult["status"] = fallbackChecks.some((c) => c.level === "error")
? "fail"
: fallbackChecks.some((c) => c.level === "warn")
? "warn"
: "pass";
if (status === "fail") releaseStatus = "failed";
const synthesized: AdapterEnvironmentTestResult = {
adapterType: type,
status,
checks: fallbackChecks,
testedAt: new Date().toISOString(),
};
res.json(synthesized);
return;
}
if (fallbackChecks.length > 0) {
const checks = [...fallbackChecks, ...result.checks];
const status: typeof result.status = checks.some((c) => c.level === "error")
? "fail"
: checks.some((c) => c.level === "warn")
? "warn"
: result.status;
res.json({ ...result, checks, status });
return;
const result = await adapter.testEnvironment({
companyId,
adapterType: type,
config: runtimeAdapterConfig,
executionTarget,
environmentName,
});
if (result.status === "fail") releaseStatus = "failed";
res.json(result);
} catch (err) {
releaseStatus = "failed";
throw err;
} finally {
await release(releaseStatus);
}
res.json(result);
},
);
@@ -1997,6 +2189,14 @@ export function agentRoutes(
lastHeartbeatAt: null,
});
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
const agentEnv = asRecord(agent.adapterConfig)?.env;
if (agentEnv) {
await secretsSvc.syncEnvBindingsForTarget?.(
companyId,
{ targetType: "agent", targetId: agent.id },
agentEnv,
);
}
const actor = getActorInfo(req);
await logActivity(db, {
@@ -2473,6 +2673,14 @@ export function agentRoutes(
res.status(404).json({ error: "Agent not found" });
return;
}
if (touchesAdapterConfiguration) {
const agentEnv = asRecord(agent.adapterConfig)?.env;
await secretsSvc.syncEnvBindingsForTarget?.(
agent.companyId,
{ targetType: "agent", targetId: agent.id },
agentEnv,
);
}
await logActivity(db, {
companyId: agent.companyId,
@@ -2691,7 +2899,25 @@ export function agentRoutes(
res.json({ ok: true });
});
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
// Shared handler body for the wakeup-style endpoints. The two routes differ
// only in:
// - `source` — the modern /wakeup endpoint reads it from the request body
// (timer|assignment|on_demand|automation) while the legacy
// /heartbeat/invoke endpoint hardcodes "on_demand", since it has only
// ever produced on-demand invocations.
// - skipped-response shape — the modern endpoint surfaces the rich
// SkippedWakeupResponse; the legacy endpoint stays on the simpler
// { status: "skipped" } shape for backward compat.
type HeartbeatSource = "timer" | "assignment" | "on_demand" | "automation";
type WakeupRouteOpts = {
source: HeartbeatSource | undefined;
skippedResponse: (agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) => unknown | Promise<unknown>;
};
const handleWakeupRoute = async (
req: Request,
res: Response,
opts: WakeupRouteOpts,
): Promise<void> => {
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
@@ -2710,7 +2936,7 @@ export function agentRoutes(
}
const run = await heartbeat.wakeup(id, {
source: req.body.source,
source: opts.source,
triggerDetail: req.body.triggerDetail ?? "manual",
reason: req.body.reason ?? null,
payload: req.body.payload ?? null,
@@ -2725,7 +2951,7 @@ export function agentRoutes(
});
if (!run) {
res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
res.status(202).json(await opts.skippedResponse(agent));
return;
}
@@ -2743,9 +2969,23 @@ export function agentRoutes(
});
res.status(202).json(run);
};
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
await handleWakeupRoute(req, res, {
source: req.body.source,
skippedResponse: (agent) => buildSkippedWakeupResponse(agent, req.body.payload ?? null),
});
});
router.post("/agents/:id/heartbeat/invoke", async (req, res) => {
// Legacy endpoint. Hardcodes `source: "on_demand"` (the prior behavior
// before the wakeup/invoke convergence). Reads scope fields directly off
// the body without `validate(wakeAgentSchema)` because callers — including
// the e2e suite — post an empty body, and the schema rejects undefined
// / missing bodies. Only forwards fields the caller actually supplied so
// an empty body produces the original fixed-arg `heartbeat.invoke()`
// shape exactly.
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
@@ -2763,19 +3003,37 @@ export function agentRoutes(
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
const run = await heartbeat.invoke(
id,
"on_demand",
{
triggeredBy: req.actor.type,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
},
"manual",
{
actorType: req.actor.type === "agent" ? "agent" : "user",
actorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
},
);
const body = (req.body ?? {}) as Partial<{
reason: unknown;
payload: unknown;
idempotencyKey: unknown;
forceFreshSession: unknown;
triggerDetail: unknown;
}>;
const contextSnapshot: Record<string, unknown> = {
triggeredBy: req.actor.type,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
};
if (body.forceFreshSession === true) {
contextSnapshot.forceFreshSession = true;
}
const wakeOpts: Parameters<typeof heartbeat.wakeup>[1] = {
source: "on_demand",
triggerDetail: typeof body.triggerDetail === "string" ? body.triggerDetail as "manual" | "system" | "ping" | "callback" : "manual",
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
contextSnapshot,
};
if (typeof body.reason === "string" && body.reason.length > 0) {
wakeOpts.reason = body.reason;
}
if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) {
wakeOpts.payload = body.payload as Record<string, unknown>;
}
if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) {
wakeOpts.idempotencyKey = body.idempotencyKey;
}
const run = await heartbeat.wakeup(id, wakeOpts);
if (!run) {
res.status(202).json({ status: "skipped" });
@@ -3082,8 +3340,8 @@ export function agentRoutes(
router.get("/issues/:issueId/live-runs", async (req, res) => {
const rawId = req.params.issueId as string;
const issueSvc = issueService(db);
const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
const identifier = normalizeIssueIdentifier(rawId);
const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
@@ -3136,8 +3394,8 @@ export function agentRoutes(
router.get("/issues/:issueId/active-run", async (req, res) => {
const rawId = req.params.issueId as string;
const issueSvc = issueService(db);
const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
const identifier = normalizeIssueIdentifier(rawId);
const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
+6 -3
View File
@@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
import {
createCostEventSchema,
createFinanceEventSchema,
normalizeIssueIdentifier,
resolveBudgetIncidentSchema,
updateBudgetSchema,
upsertBudgetPolicySchema,
@@ -62,8 +63,9 @@ export function costRoutes(
const issues = issueService(db);
async function resolveIssueByRef(rawId: string) {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
return issues.getByIdentifier(rawId);
const identifier = normalizeIssueIdentifier(rawId);
if (identifier) {
return issues.getByIdentifier(identifier);
}
return issues.getById(rawId);
}
@@ -143,7 +145,8 @@ export function costRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
const summary = await costs.issueTreeSummary(issue.companyId, issue.id);
const excludeRoot = req.query.excludeRoot === "true" || req.query.excludeRoot === "1";
const summary = await costs.issueTreeSummary(issue.companyId, issue.id, { excludeRoot });
res.json(summary);
});
+16
View File
@@ -17,6 +17,7 @@ import {
projectService,
} from "../services/index.js";
import {
collectEnvironmentSecretRefs,
normalizeEnvironmentConfigForPersistence,
normalizeEnvironmentConfigForProbe,
parseEnvironmentDriverConfig,
@@ -26,6 +27,7 @@ import {
import { probeEnvironment } from "../services/environment-probe.js";
import { secretService } from "../services/secrets.js";
import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
@@ -202,6 +204,7 @@ export function environmentRoutes(
companyId,
environmentName: req.body.name,
driver: req.body.driver,
secretProvider: getConfiguredSecretProvider(),
config: req.body.config,
actor: {
agentId: actor.agentId,
@@ -211,6 +214,11 @@ export function environmentRoutes(
}),
};
const environment = await svc.create(companyId, input);
await secrets.syncSecretRefsForTarget(
companyId,
{ targetType: "environment", targetId: environment.id },
await collectEnvironmentSecretRefs({ db, environment }),
);
await logActivity(db, {
companyId,
actorType: actor.actorType,
@@ -305,6 +313,7 @@ export function environmentRoutes(
companyId: existing.companyId,
environmentName: nextName,
driver: nextDriver,
secretProvider: getConfiguredSecretProvider(),
config: configSource,
actor: {
agentId: actor.agentId,
@@ -320,6 +329,13 @@ export function environmentRoutes(
res.status(404).json({ error: "Environment not found" });
return;
}
if (patch.config !== undefined || patch.driver !== undefined) {
await secrets.syncSecretRefsForTarget(
environment.companyId,
{ targetType: "environment", targetId: environment.id },
await collectEnvironmentSecretRefs({ db, environment }),
);
}
await logActivity(db, {
companyId: environment.companyId,
actorType: actor.actorType,
File diff suppressed because it is too large Load Diff
+163
View File
@@ -66,6 +66,17 @@ import {
getActorInfo,
} from "./authz.js";
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
import {
findLocalFolderDeclaration,
getStoredLocalFolders,
inspectPluginLocalFolder,
requireLocalFolderDeclaration,
setStoredLocalFolder,
} from "../services/plugin-local-folders.js";
import {
extractSecretRefPathsFromConfig,
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
} from "../services/plugin-secrets-handler.js";
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
/** UI slot declaration extracted from plugin manifest */
@@ -1934,6 +1945,12 @@ export function pluginRoutes(
}
try {
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
if (secretRefsByPath.size > 0) {
res.status(422).json({ error: PLUGIN_SECRET_REFS_DISABLED_MESSAGE });
return;
}
const result = await registry.upsertConfig(plugin.id, {
configJson: body.configJson,
});
@@ -2379,6 +2396,152 @@ export function pluginRoutes(
}
});
// ===========================================================================
// Company-scoped trusted local folders
// ===========================================================================
router.get("/plugins/:pluginId/companies/:companyId/local-folders", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const settings = await registry.getCompanySettings(plugin.id, companyId);
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
const declarations = plugin.manifestJson.localFolders ?? [];
const folderKeys = declarations.map((declaration) => declaration.folderKey);
const statuses = await Promise.all(folderKeys.map((folderKey) =>
inspectPluginLocalFolder({
folderKey,
declaration: findLocalFolderDeclaration(declarations, folderKey),
storedConfig: storedFolders[folderKey] ?? null,
})));
res.json({
pluginId: plugin.id,
companyId,
declarations,
folders: statuses,
});
});
router.get("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId, folderKey } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const settings = await registry.getCompanySettings(plugin.id, companyId);
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
const declarations = plugin.manifestJson.localFolders ?? [];
const declaration = requireLocalFolderDeclaration(declarations, folderKey);
const status = await inspectPluginLocalFolder({
folderKey,
declaration,
storedConfig: storedFolders[folderKey] ?? null,
});
res.json(status);
});
router.post("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId, folderKey } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const body = req.body as {
path?: unknown;
access?: "read" | "readWrite";
requiredDirectories?: string[];
requiredFiles?: string[];
} | undefined;
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
return;
}
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
const status = await inspectPluginLocalFolder({
folderKey,
declaration,
overrideConfig: {
path: body.path,
},
});
res.json(status);
});
router.put("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId, folderKey } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const body = req.body as {
path?: unknown;
access?: "read" | "readWrite";
requiredDirectories?: string[];
requiredFiles?: string[];
} | undefined;
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
return;
}
const existing = await registry.getCompanySettings(plugin.id, companyId);
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
const status = await inspectPluginLocalFolder({
folderKey,
declaration,
storedConfig: getStoredLocalFolders(existing?.settingsJson)[folderKey] ?? null,
overrideConfig: {
path: body.path,
},
});
const nextSettings = setStoredLocalFolder(existing?.settingsJson, folderKey, {
path: body.path,
access: status.access,
requiredDirectories: status.requiredDirectories,
requiredFiles: status.requiredFiles,
});
await registry.upsertCompanySettings(plugin.id, companyId, {
enabled: existing?.enabled ?? true,
settingsJson: nextSettings,
lastError: status.healthy ? null : status.problems.map((item: { message: string }) => item.message).join("; "),
});
await logPluginMutationActivity(req, "plugin.local_folder.configured", plugin.id, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
companyId,
folderKey,
healthy: status.healthy,
});
res.json(status);
});
// ===========================================================================
// Plugin health dashboard — aggregated diagnostics for the settings page
// ===========================================================================
+14
View File
@@ -142,6 +142,13 @@ export function projectRoutes(db: Db) {
);
}
const project = await svc.create(companyId, projectData);
if (project.env) {
await secretsSvc.syncEnvBindingsForTarget?.(
companyId,
{ targetType: "project", targetId: project.id },
project.env,
);
}
let createdWorkspaceId: string | null = null;
if (workspace) {
const createdWorkspace = await svc.createWorkspace(project.id, workspace);
@@ -207,6 +214,13 @@ export function projectRoutes(db: Db) {
res.status(404).json({ error: "Project not found" });
return;
}
if (body.env !== undefined) {
await secretsSvc.syncEnvBindingsForTarget?.(
project.companyId,
{ targetType: "project", targetId: project.id },
project.env,
);
}
const actor = getActorInfo(req);
await logActivity(db, {
+137 -3
View File
@@ -57,6 +57,34 @@ export function routineRoutes(
return routine;
}
async function logRoutineRevisionCreated(req: Request, input: {
companyId: string;
routineId: string;
revisionId: string | null;
revisionNumber: number;
changeSummary?: string | null;
triggerCount?: number | null;
}) {
if (!input.revisionId) return;
const actor = getActorInfo(req);
await logActivity(db, {
companyId: input.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.revision_created",
entityType: "routine",
entityId: input.routineId,
details: {
revisionId: input.revisionId,
revisionNumber: input.revisionNumber,
changeSummary: input.changeSummary ?? null,
triggerCount: input.triggerCount ?? null,
},
});
}
router.get("/companies/:companyId/routines", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -72,6 +100,7 @@ export function routineRoutes(
const created = await svc.create(companyId, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
@@ -89,6 +118,14 @@ export function routineRoutes(
if (telemetryClient) {
trackRoutineCreated(telemetryClient);
}
await logRoutineRevisionCreated(req, {
companyId,
routineId: created.id,
revisionId: created.latestRevisionId,
revisionNumber: created.latestRevisionNumber,
changeSummary: "Created routine",
triggerCount: 0,
});
res.status(201).json(created);
});
@@ -102,6 +139,16 @@ export function routineRoutes(
res.json(detail);
});
router.get("/routines/:id/revisions", async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
const revisions = await svc.listRevisions(routine.id);
res.json(revisions);
});
router.patch("/routines/:id", validate(updateRoutineSchema), async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
@@ -131,6 +178,7 @@ export function routineRoutes(
const updated = await svc.update(routine.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
@@ -144,9 +192,52 @@ export function routineRoutes(
entityId: routine.id,
details: { title: updated?.title ?? routine.title },
});
if (updated && updated.latestRevisionId !== routine.latestRevisionId) {
await logRoutineRevisionCreated(req, {
companyId: routine.companyId,
routineId: routine.id,
revisionId: updated.latestRevisionId,
revisionNumber: updated.latestRevisionNumber,
changeSummary: "Updated routine",
triggerCount: null,
});
}
res.json(updated);
});
router.post("/routines/:id/revisions/:revisionId/restore", async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
const result = await svc.restoreRevision(routine.id, req.params.revisionId as string, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.revision_restored",
entityType: "routine",
entityId: routine.id,
details: {
revisionId: result.revision.id,
revisionNumber: result.revision.revisionNumber,
restoredFromRevisionId: result.restoredFromRevisionId,
restoredFromRevisionNumber: result.restoredFromRevisionNumber,
triggerCount: result.revision.snapshot.triggers.length,
},
});
res.json(result);
});
router.get("/routines/:id/runs", async (req, res) => {
const routine = await svc.get(req.params.id as string);
if (!routine) {
@@ -169,6 +260,7 @@ export function routineRoutes(
const created = await svc.createTrigger(routine.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
@@ -182,6 +274,14 @@ export function routineRoutes(
entityId: created.trigger.id,
details: { routineId: routine.id, kind: created.trigger.kind },
});
await logRoutineRevisionCreated(req, {
companyId: routine.companyId,
routineId: routine.id,
revisionId: created.revision.id,
revisionNumber: created.revision.revisionNumber,
changeSummary: created.revision.changeSummary,
triggerCount: created.revision.snapshot.triggers.length,
});
res.status(201).json(created);
});
@@ -200,6 +300,7 @@ export function routineRoutes(
const updated = await svc.updateTrigger(trigger.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
@@ -211,9 +312,19 @@ export function routineRoutes(
action: "routine.trigger_updated",
entityType: "routine_trigger",
entityId: trigger.id,
details: { routineId: routine.id, kind: updated?.kind ?? trigger.kind },
details: { routineId: routine.id, kind: updated?.trigger.kind ?? trigger.kind },
});
res.json(updated);
if (updated) {
await logRoutineRevisionCreated(req, {
companyId: routine.companyId,
routineId: routine.id,
revisionId: updated.revision.id,
revisionNumber: updated.revision.revisionNumber,
changeSummary: updated.revision.changeSummary,
triggerCount: updated.revision.snapshot.triggers.length,
});
}
res.json(updated?.trigger ?? null);
});
router.delete("/routine-triggers/:id", async (req, res) => {
@@ -227,7 +338,11 @@ export function routineRoutes(
res.status(404).json({ error: "Routine not found" });
return;
}
await svc.deleteTrigger(trigger.id);
const deleted = await svc.deleteTrigger(trigger.id, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
@@ -240,6 +355,16 @@ export function routineRoutes(
entityId: trigger.id,
details: { routineId: routine.id, kind: trigger.kind },
});
if (deleted.revision) {
await logRoutineRevisionCreated(req, {
companyId: routine.companyId,
routineId: routine.id,
revisionId: deleted.revision.id,
revisionNumber: deleted.revision.revisionNumber,
changeSummary: deleted.revision.changeSummary,
triggerCount: deleted.revision.snapshot.triggers.length,
});
}
res.status(204).end();
});
@@ -260,6 +385,7 @@ export function routineRoutes(
const rotated = await svc.rotateTriggerSecret(trigger.id, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
@@ -273,6 +399,14 @@ export function routineRoutes(
entityId: trigger.id,
details: { routineId: routine.id },
});
await logRoutineRevisionCreated(req, {
companyId: routine.companyId,
routineId: routine.id,
revisionId: rotated.revision.id,
revisionNumber: rotated.revision.revisionNumber,
changeSummary: rotated.revision.changeSummary,
triggerCount: rotated.revision.snapshot.triggers.length,
});
res.json(rotated);
},
);
+321 -8
View File
@@ -1,25 +1,23 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
SECRET_PROVIDERS,
type SecretProvider,
createSecretProviderConfigSchema,
createSecretSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
rotateSecretSchema,
updateSecretProviderConfigSchema,
updateSecretSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { logActivity, secretService } from "../services/index.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
export function secretRoutes(db: Db) {
const router = Router();
const svc = secretService(db);
const configuredDefaultProvider = process.env.PAPERCLIP_SECRETS_PROVIDER;
const defaultProvider = (
configuredDefaultProvider && SECRET_PROVIDERS.includes(configuredDefaultProvider as SecretProvider)
? configuredDefaultProvider
: "local_encrypted"
) as SecretProvider;
const defaultProvider = getConfiguredSecretProvider();
router.get("/companies/:companyId/secret-providers", (req, res) => {
assertBoard(req);
@@ -28,6 +26,205 @@ export function secretRoutes(db: Db) {
res.json(svc.listProviders());
});
router.get("/companies/:companyId/secret-providers/health", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const checks = await svc.checkProviders();
res.json({ providers: checks });
});
router.get("/companies/:companyId/secret-provider-configs", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.listProviderConfigs(companyId));
});
router.post("/companies/:companyId/secret-provider-configs", validate(createSecretProviderConfigSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const created = await svc.createProviderConfig(
companyId,
{
provider: req.body.provider,
displayName: req.body.displayName,
status: req.body.status,
isDefault: req.body.isDefault,
config: req.body.config,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.created",
entityType: "secret_provider_config",
entityId: created.id,
details: {
provider: created.provider,
displayName: created.displayName,
status: created.status,
isDefault: created.isDefault,
},
});
res.status(201).json(created);
});
router.get("/secret-provider-configs/:id", async (req, res) => {
assertBoard(req);
const existing = await svc.getProviderConfigById(req.params.id as string);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
res.json(existing);
});
router.patch("/secret-provider-configs/:id", validate(updateSecretProviderConfigSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const updated = await svc.updateProviderConfig(id, {
displayName: req.body.displayName,
status: req.body.status,
isDefault: req.body.isDefault,
config: req.body.config,
});
if (!updated) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: updated.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.updated",
entityType: "secret_provider_config",
entityId: updated.id,
details: {
provider: updated.provider,
displayName: updated.displayName,
status: updated.status,
isDefault: updated.isDefault,
},
});
res.json(updated);
});
router.delete("/secret-provider-configs/:id", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const disabled = await svc.disableProviderConfig(id);
if (!disabled) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: disabled.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.disabled",
entityType: "secret_provider_config",
entityId: disabled.id,
details: {
provider: disabled.provider,
displayName: disabled.displayName,
status: disabled.status,
},
});
res.json(disabled);
});
router.post("/secret-provider-configs/:id/default", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const updated = await svc.setDefaultProviderConfig(id);
if (!updated) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: updated.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.default_set",
entityType: "secret_provider_config",
entityId: updated.id,
details: {
provider: updated.provider,
displayName: updated.displayName,
isDefault: updated.isDefault,
},
});
res.json(updated);
});
router.post("/secret-provider-configs/:id/health", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const health = await svc.checkProviderConfigHealth(id);
if (!health) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: existing.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.health_checked",
entityType: "secret_provider_config",
entityId: existing.id,
details: {
provider: existing.provider,
status: health.status,
code: health.details.code,
},
});
res.json(health);
});
router.get("/companies/:companyId/secrets", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
@@ -45,10 +242,15 @@ export function secretRoutes(db: Db) {
companyId,
{
name: req.body.name,
key: req.body.key,
provider: req.body.provider ?? defaultProvider,
providerConfigId: req.body.providerConfigId,
managedMode: req.body.managedMode,
value: req.body.value,
description: req.body.description,
externalRef: req.body.externalRef,
providerVersionRef: req.body.providerVersionRef,
providerMetadata: req.body.providerMetadata,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
@@ -66,6 +268,77 @@ export function secretRoutes(db: Db) {
res.status(201).json(created);
});
router.post(
"/companies/:companyId/secrets/remote-import/preview",
validate(remoteSecretImportPreviewSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const preview = await svc.previewRemoteImport(companyId, {
providerConfigId: req.body.providerConfigId,
query: req.body.query,
nextToken: req.body.nextToken,
pageSize: req.body.pageSize,
});
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.remote_import.previewed",
entityType: "secret_provider_config",
entityId: preview.providerConfigId,
details: {
provider: preview.provider,
candidateCount: preview.candidates.length,
readyCount: preview.candidates.filter((candidate) => candidate.status === "ready").length,
duplicateCount: preview.candidates.filter((candidate) => candidate.status === "duplicate").length,
conflictCount: preview.candidates.filter((candidate) => candidate.status === "conflict").length,
},
});
res.json(preview);
},
);
router.post(
"/companies/:companyId/secrets/remote-import",
validate(remoteSecretImportSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.importRemoteSecrets(
companyId,
{
providerConfigId: req.body.providerConfigId,
secrets: req.body.secrets,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.remote_import.completed",
entityType: "secret_provider_config",
entityId: result.providerConfigId,
details: {
provider: result.provider,
importedCount: result.importedCount,
skippedCount: result.skippedCount,
errorCount: result.errorCount,
},
});
res.json(result);
},
);
router.post("/secrets/:id/rotate", validate(rotateSecretSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
@@ -75,12 +348,18 @@ export function secretRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
return;
}
const rotated = await svc.rotate(
id,
{
value: req.body.value,
externalRef: req.body.externalRef,
providerVersionRef: req.body.providerVersionRef,
providerConfigId: req.body.providerConfigId,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
@@ -107,11 +386,19 @@ export function secretRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
return;
}
const updated = await svc.update(id, {
name: req.body.name,
key: req.body.key,
status: req.body.status,
providerConfigId: req.body.providerConfigId,
description: req.body.description,
externalRef: req.body.externalRef,
providerMetadata: req.body.providerMetadata,
});
if (!updated) {
@@ -145,6 +432,32 @@ export function secretRoutes(db: Db) {
res.json({ agents: used.agents, skills: used.skills });
});
router.get("/secrets/:id/usage", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const bindings = await svc.listBindingReferences(existing.companyId, existing.id);
res.json({ secretId: existing.id, bindings });
});
router.get("/secrets/:id/access-events", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const events = await svc.listAccessEvents(existing.companyId, existing.id);
res.json(events);
});
router.delete("/secrets/:id", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
import { SECRET_PROVIDERS, type SecretProvider } from "@paperclipai/shared";
export function getConfiguredSecretProvider(): SecretProvider {
const configuredProvider = process.env.PAPERCLIP_SECRETS_PROVIDER;
return configuredProvider && SECRET_PROVIDERS.includes(configuredProvider as SecretProvider)
? configuredProvider as SecretProvider
: "local_encrypted";
}
+61 -6
View File
@@ -1,23 +1,78 @@
import { unprocessable } from "../errors.js";
import type { SecretProviderModule } from "./types.js";
import type { PreparedSecretVersion, SecretProviderModule } from "./types.js";
import { createHash } from "node:crypto";
function unavailableProvider(
id: "aws_secrets_manager" | "gcp_secret_manager" | "vault",
label: string,
): SecretProviderModule {
function externalFingerprint(externalRef: string, providerVersionRef: string | null): string {
return createHash("sha256")
.update(`${id}:${externalRef}:${providerVersionRef ?? ""}`)
.digest("hex");
}
function prepareExternalReference(input: {
externalRef: string;
providerVersionRef?: string | null;
}): PreparedSecretVersion {
const externalRef = input.externalRef.trim();
const providerVersionRef = input.providerVersionRef?.trim() || null;
const fingerprint = externalFingerprint(externalRef, providerVersionRef);
return {
material: {
scheme: "external_reference_v1",
provider: id,
externalRef,
providerVersionRef,
},
valueSha256: fingerprint,
fingerprintSha256: fingerprint,
externalRef,
providerVersionRef,
};
}
return {
id,
descriptor: {
id,
label,
requiresExternalRef: true,
descriptor() {
return {
id,
label,
requiresExternalRef: true,
supportsManagedValues: false,
supportsExternalReferences: true,
configured: false,
};
},
async validateConfig() {
return { ok: false, warnings: [`${id} provider is not configured in this deployment`] };
},
async createSecret() {
throw unprocessable(`${id} provider is not configured for Paperclip-managed values`);
},
async createVersion() {
throw unprocessable(`${id} provider is not configured in this deployment`);
throw unprocessable(`${id} provider is not configured for Paperclip-managed values`);
},
async linkExternalSecret(input) {
return prepareExternalReference(input);
},
async resolveVersion() {
throw unprocessable(`${id} provider is not configured in this deployment`);
},
async deleteOrArchive() {
// External references are metadata-only in Paperclip for unconfigured providers.
},
async healthCheck() {
return {
provider: id,
status: "warn",
message: `${id} provider is available for external references but not configured for runtime resolution`,
warnings: [
"Linked external references can be stored as metadata, but runtime resolution will fail until this provider is configured.",
],
};
},
};
}
+153 -13
View File
@@ -1,7 +1,14 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { SecretProviderModule, StoredSecretVersionMaterial } from "./types.js";
import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js";
import type {
PreparedSecretVersion,
SecretProviderHealthCheck,
SecretProviderModule,
SecretProviderValidationResult,
StoredSecretVersionMaterial,
} from "./types.js";
import { badRequest } from "../errors.js";
interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
@@ -14,7 +21,7 @@ interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
function resolveMasterKeyFilePath() {
const fromEnv = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
if (fromEnv && fromEnv.trim().length > 0) return path.resolve(fromEnv.trim());
return path.resolve(process.cwd(), "data/secrets/master.key");
return resolveDefaultSecretsKeyFilePath();
}
function decodeMasterKey(raw: string): Buffer | null {
@@ -52,6 +59,7 @@ function loadOrCreateMasterKey(): Buffer {
const keyPath = resolveMasterKeyFilePath();
if (existsSync(keyPath)) {
enforceKeyFilePermissionsBestEffort(keyPath);
const raw = readFileSync(keyPath, "utf8");
const decoded = decodeMasterKey(raw);
if (!decoded) {
@@ -72,10 +80,118 @@ function loadOrCreateMasterKey(): Buffer {
return generated;
}
function enforceKeyFilePermissionsBestEffort(keyPath: string) {
try {
const mode = statSync(keyPath).mode & 0o777;
if ((mode & 0o077) !== 0) {
chmodSync(keyPath, 0o600);
}
} catch {
// best effort only; health checks surface persistent permission problems.
}
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function prepareManagedVersion(value: string): PreparedSecretVersion {
const masterKey = loadOrCreateMasterKey();
const valueSha256 = sha256Hex(value);
return {
material: encryptValue(masterKey, value),
valueSha256,
fingerprintSha256: valueSha256,
externalRef: null,
};
}
async function inspectLocalEncryptedHealth(): Promise<SecretProviderHealthCheck> {
const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
if (envKeyRaw && envKeyRaw.trim().length > 0) {
if (!decodeMasterKey(envKeyRaw)) {
return {
provider: "local_encrypted",
status: "error",
message:
"PAPERCLIP_SECRETS_MASTER_KEY is invalid; expected 32-byte base64, 64-char hex, or raw 32-char string",
};
}
return {
provider: "local_encrypted",
status: "ok",
message: "Local encrypted provider is using PAPERCLIP_SECRETS_MASTER_KEY",
backupGuidance: [
"Back up the configured master key separately from the database.",
"A restore needs both the database metadata and the same master key.",
],
details: { keySource: "env" },
};
}
const keyPath = resolveMasterKeyFilePath();
if (!existsSync(keyPath)) {
return {
provider: "local_encrypted",
status: "warn",
message: `Secrets key file does not exist yet: ${keyPath}`,
warnings: ["The first managed secret write will create this key file with 0600 permissions."],
backupGuidance: [
"Back up the key file together with database backups.",
"The database alone cannot restore local encrypted secret values.",
],
details: { keySource: "file", keyFilePath: keyPath },
};
}
let mode: number | null = null;
try {
mode = statSync(keyPath).mode & 0o777;
} catch (err) {
return {
provider: "local_encrypted",
status: "error",
message: `Could not stat secrets key file: ${err instanceof Error ? err.message : String(err)}`,
details: { keySource: "file", keyFilePath: keyPath },
};
}
try {
const raw = readFileSync(keyPath, "utf8");
if (!decodeMasterKey(raw)) {
return {
provider: "local_encrypted",
status: "error",
message: `Invalid key material in ${keyPath}`,
details: { keySource: "file", keyFilePath: keyPath },
};
}
} catch (err) {
return {
provider: "local_encrypted",
status: "error",
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
details: { keySource: "file", keyFilePath: keyPath },
};
}
const warnings =
mode !== null && (mode & 0o077) !== 0
? [`Secrets key file permissions are ${mode.toString(8)}; run chmod 600 ${keyPath}`]
: [];
return {
provider: "local_encrypted",
status: warnings.length > 0 ? "warn" : "ok",
message: `Local encrypted provider configured with key file ${keyPath}`,
warnings,
backupGuidance: [
"Back up the key file together with database backups.",
"The database alone cannot restore local encrypted secret values.",
],
details: { keySource: "file", keyFilePath: keyPath },
};
}
function encryptValue(masterKey: Buffer, value: string): LocalEncryptedMaterial {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", masterKey, iv);
@@ -115,21 +231,45 @@ function asLocalEncryptedMaterial(value: StoredSecretVersionMaterial): LocalEncr
export const localEncryptedProvider: SecretProviderModule = {
id: "local_encrypted",
descriptor: {
id: "local_encrypted",
label: "Local encrypted (default)",
requiresExternalRef: false,
descriptor() {
return {
id: "local_encrypted",
label: "Local encrypted (default)",
requiresExternalRef: false,
supportsManagedValues: true,
supportsExternalReferences: false,
configured: true,
};
},
async validateConfig(input): Promise<SecretProviderValidationResult> {
const warnings: string[] = [];
if (input?.deploymentMode === "authenticated" && input.strictMode !== true) {
warnings.push("Strict secret mode should be enabled for authenticated deployments");
}
const health = await inspectLocalEncryptedHealth();
if (health.status === "error") {
throw badRequest(health.message);
}
warnings.push(...(health.warnings ?? []));
return { ok: true, warnings };
},
async createSecret(input) {
return prepareManagedVersion(input.value);
},
async createVersion(input) {
const masterKey = loadOrCreateMasterKey();
return {
material: encryptValue(masterKey, input.value),
valueSha256: sha256Hex(input.value),
externalRef: null,
};
return prepareManagedVersion(input.value);
},
async linkExternalSecret() {
throw badRequest("local_encrypted does not support external reference secrets");
},
async resolveVersion(input) {
const masterKey = loadOrCreateMasterKey();
return decryptValue(masterKey, asLocalEncryptedMaterial(input.material));
},
async deleteOrArchive() {
// Secret metadata deletion is handled in Paperclip DB; the local key is shared and must remain.
},
async healthCheck() {
return inspectLocalEncryptedHealth();
},
};
+7 -3
View File
@@ -1,11 +1,11 @@
import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared";
import { awsSecretsManagerProvider } from "./aws-secrets-manager-provider.js";
import { localEncryptedProvider } from "./local-encrypted-provider.js";
import {
awsSecretsManagerProvider,
gcpSecretManagerProvider,
vaultProvider,
} from "./external-stub-providers.js";
import type { SecretProviderModule } from "./types.js";
import type { SecretProviderHealthCheck, SecretProviderModule } from "./types.js";
import { unprocessable } from "../errors.js";
const providers: SecretProviderModule[] = [
@@ -26,5 +26,9 @@ export function getSecretProvider(id: SecretProvider): SecretProviderModule {
}
export function listSecretProviders(): SecretProviderDescriptor[] {
return providers.map((provider) => provider.descriptor);
return providers.map((provider) => provider.descriptor());
}
export async function checkSecretProviders(): Promise<SecretProviderHealthCheck[]> {
return Promise.all(providers.map((provider) => provider.healthCheck()));
}

Some files were not shown because too many files have changed in this diff Show More