merge: wave 2 executor (01-02) into main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a5b8fcf9d3
commit
aa1a78d7d9
1081 files changed
+445341
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { activityRoutes } from "../routes/activity.js";
|
||||
|
||||
const mockActivityService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
forIssue: vi.fn(),
|
||||
runsForIssue: vi.fn(),
|
||||
issuesForRun: vi.fn(),
|
||||
create: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByIdentifier: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/activity.js", () => ({
|
||||
activityService: () => mockActivityService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
issueService: () => mockIssueService,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", activityRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("activity routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resolves issue identifiers before loading runs", async () => {
|
||||
mockIssueService.getByIdentifier.mockResolvedValue({
|
||||
id: "issue-uuid-1",
|
||||
companyId: "company-1",
|
||||
});
|
||||
mockActivityService.runsForIssue.mockResolvedValue([
|
||||
{
|
||||
runId: "run-1",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(createApp()).get("/api/issues/PAP-475/runs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475");
|
||||
expect(mockIssueService.getById).not.toHaveBeenCalled();
|
||||
expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1");
|
||||
expect(res.body).toEqual([{ runId: "run-1" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
|
||||
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
|
||||
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { listAdapterModels } from "../adapters/index.js";
|
||||
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
||||
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
|
||||
|
||||
describe("adapter model listing", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
|
||||
resetCodexModelsCacheForTests();
|
||||
resetCursorModelsCacheForTests();
|
||||
setCursorModelsRunnerForTests(null);
|
||||
resetOpenCodeModelsCacheForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns an empty list for unknown adapters", async () => {
|
||||
const models = await listAdapterModels("unknown_adapter");
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns codex fallback models when no OpenAI key is available", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const models = await listAdapterModels("codex_local");
|
||||
|
||||
expect(models).toEqual(codexFallbackModels);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads codex models dynamically and merges fallback options", async () => {
|
||||
process.env.OPENAI_API_KEY = "sk-test";
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5-pro" },
|
||||
{ id: "gpt-5" },
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const first = await listAdapterModels("codex_local");
|
||||
const second = await listAdapterModels("codex_local");
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(first).toEqual(second);
|
||||
expect(first.some((model) => model.id === "gpt-5-pro")).toBe(true);
|
||||
expect(first.some((model) => model.id === "codex-mini-latest")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to static codex models when OpenAI model discovery fails", async () => {
|
||||
process.env.OPENAI_API_KEY = "sk-test";
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
|
||||
const models = await listAdapterModels("codex_local");
|
||||
expect(models).toEqual(codexFallbackModels);
|
||||
});
|
||||
|
||||
|
||||
it("returns cursor fallback models when CLI discovery is unavailable", async () => {
|
||||
setCursorModelsRunnerForTests(() => ({
|
||||
status: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
hasError: true,
|
||||
}));
|
||||
|
||||
const models = await listAdapterModels("cursor");
|
||||
expect(models).toEqual(cursorFallbackModels);
|
||||
});
|
||||
|
||||
it("loads cursor models dynamically and caches them", async () => {
|
||||
const runner = vi.fn(() => ({
|
||||
status: 0,
|
||||
stdout: "Available models: auto, composer-1.5, gpt-5.3-codex-high, sonnet-4.6",
|
||||
stderr: "",
|
||||
hasError: false,
|
||||
}));
|
||||
setCursorModelsRunnerForTests(runner);
|
||||
|
||||
const first = await listAdapterModels("cursor");
|
||||
const second = await listAdapterModels("cursor");
|
||||
|
||||
expect(runner).toHaveBeenCalledTimes(1);
|
||||
expect(first).toEqual(second);
|
||||
expect(first.some((model) => model.id === "auto")).toBe(true);
|
||||
expect(first.some((model) => model.id === "gpt-5.3-codex-high")).toBe(true);
|
||||
expect(first.some((model) => model.id === "composer-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns no opencode models when opencode command is unavailable", async () => {
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
|
||||
|
||||
const models = await listAdapterModels("opencode_local");
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sessionCodec as claudeSessionCodec } from "@paperclipai/adapter-claude-local/server";
|
||||
import { sessionCodec as codexSessionCodec, isCodexUnknownSessionError } from "@paperclipai/adapter-codex-local/server";
|
||||
import {
|
||||
sessionCodec as cursorSessionCodec,
|
||||
isCursorUnknownSessionError,
|
||||
} from "@paperclipai/adapter-cursor-local/server";
|
||||
import {
|
||||
sessionCodec as geminiSessionCodec,
|
||||
isGeminiUnknownSessionError,
|
||||
} from "@paperclipai/adapter-gemini-local/server";
|
||||
import {
|
||||
sessionCodec as opencodeSessionCodec,
|
||||
isOpenCodeUnknownSessionError,
|
||||
} from "@paperclipai/adapter-opencode-local/server";
|
||||
|
||||
describe("adapter session codecs", () => {
|
||||
it("normalizes claude session params with cwd", () => {
|
||||
const parsed = claudeSessionCodec.deserialize({
|
||||
session_id: "claude-session-1",
|
||||
folder: "/tmp/workspace",
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
sessionId: "claude-session-1",
|
||||
cwd: "/tmp/workspace",
|
||||
});
|
||||
|
||||
const serialized = claudeSessionCodec.serialize(parsed);
|
||||
expect(serialized).toEqual({
|
||||
sessionId: "claude-session-1",
|
||||
cwd: "/tmp/workspace",
|
||||
});
|
||||
expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1");
|
||||
});
|
||||
|
||||
it("normalizes codex session params with cwd", () => {
|
||||
const parsed = codexSessionCodec.deserialize({
|
||||
sessionId: "codex-session-1",
|
||||
cwd: "/tmp/codex",
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
sessionId: "codex-session-1",
|
||||
cwd: "/tmp/codex",
|
||||
});
|
||||
|
||||
const serialized = codexSessionCodec.serialize(parsed);
|
||||
expect(serialized).toEqual({
|
||||
sessionId: "codex-session-1",
|
||||
cwd: "/tmp/codex",
|
||||
});
|
||||
expect(codexSessionCodec.getDisplayId?.(serialized ?? null)).toBe("codex-session-1");
|
||||
});
|
||||
|
||||
it("normalizes opencode session params with cwd", () => {
|
||||
const parsed = opencodeSessionCodec.deserialize({
|
||||
sessionID: "opencode-session-1",
|
||||
cwd: "/tmp/opencode",
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
sessionId: "opencode-session-1",
|
||||
cwd: "/tmp/opencode",
|
||||
});
|
||||
|
||||
const serialized = opencodeSessionCodec.serialize(parsed);
|
||||
expect(serialized).toEqual({
|
||||
sessionId: "opencode-session-1",
|
||||
cwd: "/tmp/opencode",
|
||||
});
|
||||
expect(opencodeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("opencode-session-1");
|
||||
});
|
||||
|
||||
it("normalizes cursor session params with cwd", () => {
|
||||
const parsed = cursorSessionCodec.deserialize({
|
||||
session_id: "cursor-session-1",
|
||||
cwd: "/tmp/cursor",
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
sessionId: "cursor-session-1",
|
||||
cwd: "/tmp/cursor",
|
||||
});
|
||||
|
||||
const serialized = cursorSessionCodec.serialize(parsed);
|
||||
expect(serialized).toEqual({
|
||||
sessionId: "cursor-session-1",
|
||||
cwd: "/tmp/cursor",
|
||||
});
|
||||
expect(cursorSessionCodec.getDisplayId?.(serialized ?? null)).toBe("cursor-session-1");
|
||||
});
|
||||
|
||||
it("normalizes gemini session params with cwd", () => {
|
||||
const parsed = geminiSessionCodec.deserialize({
|
||||
session_id: "gemini-session-1",
|
||||
cwd: "/tmp/gemini",
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
sessionId: "gemini-session-1",
|
||||
cwd: "/tmp/gemini",
|
||||
});
|
||||
|
||||
const serialized = geminiSessionCodec.serialize(parsed);
|
||||
expect(serialized).toEqual({
|
||||
sessionId: "gemini-session-1",
|
||||
cwd: "/tmp/gemini",
|
||||
});
|
||||
expect(geminiSessionCodec.getDisplayId?.(serialized ?? null)).toBe("gemini-session-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex resume recovery detection", () => {
|
||||
it("detects unknown session errors from codex output", () => {
|
||||
expect(
|
||||
isCodexUnknownSessionError(
|
||||
'{"type":"error","message":"Unknown session id abc"}',
|
||||
"",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCodexUnknownSessionError(
|
||||
"",
|
||||
"thread 123 not found",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCodexUnknownSessionError(
|
||||
'{"type":"result","ok":true}',
|
||||
"",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("opencode resume recovery detection", () => {
|
||||
it("detects unknown session errors from opencode output", () => {
|
||||
expect(
|
||||
isOpenCodeUnknownSessionError(
|
||||
"",
|
||||
"NotFoundError: Resource not found: /Users/test/.local/share/opencode/storage/session/proj/ses_missing.json",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isOpenCodeUnknownSessionError(
|
||||
"{\"type\":\"step_finish\",\"part\":{\"reason\":\"stop\"}}",
|
||||
"",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor resume recovery detection", () => {
|
||||
it("detects unknown session errors from cursor output", () => {
|
||||
expect(
|
||||
isCursorUnknownSessionError(
|
||||
"",
|
||||
"Error: unknown session id abc",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCursorUnknownSessionError(
|
||||
"",
|
||||
"chat abc not found",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCursorUnknownSessionError(
|
||||
"{\"type\":\"result\",\"subtype\":\"success\"}",
|
||||
"",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini resume recovery detection", () => {
|
||||
it("detects unknown session errors from gemini output", () => {
|
||||
expect(
|
||||
isGeminiUnknownSessionError(
|
||||
"",
|
||||
"unknown session id abc",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isGeminiUnknownSessionError(
|
||||
"",
|
||||
"checkpoint latest not found",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isGeminiUnknownSessionError(
|
||||
"{\"type\":\"result\",\"subtype\":\"success\"}",
|
||||
"",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createLocalAgentJwt, verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
|
||||
describe("agent local JWT", () => {
|
||||
const secretEnv = "PAPERCLIP_AGENT_JWT_SECRET";
|
||||
const ttlEnv = "PAPERCLIP_AGENT_JWT_TTL_SECONDS";
|
||||
const issuerEnv = "PAPERCLIP_AGENT_JWT_ISSUER";
|
||||
const audienceEnv = "PAPERCLIP_AGENT_JWT_AUDIENCE";
|
||||
|
||||
const originalEnv = {
|
||||
secret: process.env[secretEnv],
|
||||
ttl: process.env[ttlEnv],
|
||||
issuer: process.env[issuerEnv],
|
||||
audience: process.env[audienceEnv],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env[secretEnv] = "test-secret";
|
||||
process.env[ttlEnv] = "3600";
|
||||
delete process.env[issuerEnv];
|
||||
delete process.env[audienceEnv];
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
if (originalEnv.secret === undefined) delete process.env[secretEnv];
|
||||
else process.env[secretEnv] = originalEnv.secret;
|
||||
if (originalEnv.ttl === undefined) delete process.env[ttlEnv];
|
||||
else process.env[ttlEnv] = originalEnv.ttl;
|
||||
if (originalEnv.issuer === undefined) delete process.env[issuerEnv];
|
||||
else process.env[issuerEnv] = originalEnv.issuer;
|
||||
if (originalEnv.audience === undefined) delete process.env[audienceEnv];
|
||||
else process.env[audienceEnv] = originalEnv.audience;
|
||||
});
|
||||
|
||||
it("creates and verifies a token", () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
||||
expect(typeof token).toBe("string");
|
||||
|
||||
const claims = verifyLocalAgentJwt(token!);
|
||||
expect(claims).toMatchObject({
|
||||
sub: "agent-1",
|
||||
company_id: "company-1",
|
||||
adapter_type: "claude_local",
|
||||
run_id: "run-1",
|
||||
iss: "paperclip",
|
||||
aud: "paperclip-api",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when secret is missing", () => {
|
||||
process.env[secretEnv] = "";
|
||||
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
||||
expect(token).toBeNull();
|
||||
expect(verifyLocalAgentJwt("abc.def.ghi")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects expired tokens", () => {
|
||||
process.env[ttlEnv] = "1";
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:05.000Z"));
|
||||
expect(verifyLocalAgentJwt(token!)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects issuer/audience mismatch", () => {
|
||||
process.env[issuerEnv] = "custom-issuer";
|
||||
process.env[audienceEnv] = "custom-audience";
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const token = createLocalAgentJwt("agent-1", "company-1", "codex_local", "run-1");
|
||||
|
||||
process.env[issuerEnv] = "paperclip";
|
||||
process.env[audienceEnv] = "paperclip-api";
|
||||
expect(verifyLocalAgentJwt(token!)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentInstructionsService = vi.hoisted(() => ({
|
||||
getBundle: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
updateBundle: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
exportFiles: vi.fn(),
|
||||
ensureManagedBundle: vi.fn(),
|
||||
materializeManagedBundle: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
resolveAdapterConfigForRuntime: vi.fn(),
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => ({}),
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
heartbeatService: () => ({}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => ({}),
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(),
|
||||
listAdapterModels: vi.fn(),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
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;
|
||||
}
|
||||
|
||||
function makeAgent() {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
name: "Agent",
|
||||
role: "engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent instructions bundle routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent());
|
||||
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeAgent(),
|
||||
adapterConfig: patch.adapterConfig ?? {},
|
||||
}));
|
||||
mockAgentInstructionsService.getBundle.mockResolvedValue({
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
mode: "managed",
|
||||
rootPath: "/tmp/agent-1",
|
||||
managedRootPath: "/tmp/agent-1",
|
||||
entryFile: "AGENTS.md",
|
||||
resolvedEntryPath: "/tmp/agent-1/AGENTS.md",
|
||||
editable: true,
|
||||
warnings: [],
|
||||
legacyPromptTemplateActive: false,
|
||||
legacyBootstrapPromptTemplateActive: false,
|
||||
files: [{
|
||||
path: "AGENTS.md",
|
||||
size: 12,
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
isEntryFile: true,
|
||||
editable: true,
|
||||
deprecated: false,
|
||||
virtual: false,
|
||||
}],
|
||||
});
|
||||
mockAgentInstructionsService.readFile.mockResolvedValue({
|
||||
path: "AGENTS.md",
|
||||
size: 12,
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
isEntryFile: true,
|
||||
editable: true,
|
||||
deprecated: false,
|
||||
virtual: false,
|
||||
content: "# Agent\n",
|
||||
});
|
||||
mockAgentInstructionsService.writeFile.mockResolvedValue({
|
||||
bundle: null,
|
||||
file: {
|
||||
path: "AGENTS.md",
|
||||
size: 18,
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
isEntryFile: true,
|
||||
editable: true,
|
||||
deprecated: false,
|
||||
virtual: false,
|
||||
content: "# Updated Agent\n",
|
||||
},
|
||||
adapterConfig: {
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns bundle metadata", async () => {
|
||||
const res = await request(createApp())
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
mode: "managed",
|
||||
rootPath: "/tmp/agent-1",
|
||||
managedRootPath: "/tmp/agent-1",
|
||||
entryFile: "AGENTS.md",
|
||||
});
|
||||
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes a bundle file and persists compatibility config", async () => {
|
||||
const res = await request(createApp())
|
||||
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
|
||||
.send({
|
||||
path: "AGENTS.md",
|
||||
content: "# Updated Agent\n",
|
||||
clearLegacyPromptTemplate: true,
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentInstructionsService.writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
|
||||
"AGENTS.md",
|
||||
"# Updated Agent\n",
|
||||
{ clearLegacyPromptTemplate: true },
|
||||
);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves managed instructions config when switching adapters", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...makeAgent(),
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
model: "claude-sonnet-4",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: expect.objectContaining({
|
||||
model: "claude-sonnet-4",
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges same-adapter config patches so instructions metadata is not dropped", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...makeAgent(),
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
adapterConfig: {
|
||||
command: "codex --profile engineer",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
command: "codex --profile engineer",
|
||||
model: "gpt-5.4",
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces adapter config when replaceAdapterConfig is true", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...makeAgent(),
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: "/tmp/agent-1",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
replaceAdapterConfig: true,
|
||||
adapterConfig: {
|
||||
command: "codex --profile engineer",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
command: "codex --profile engineer",
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(res.body.adapterConfig).toMatchObject({
|
||||
command: "codex --profile engineer",
|
||||
});
|
||||
expect(res.body.adapterConfig.instructionsBundleMode).toBeUndefined();
|
||||
expect(res.body.adapterConfig.instructionsRootPath).toBeUndefined();
|
||||
expect(res.body.adapterConfig.instructionsEntryFile).toBeUndefined();
|
||||
expect(res.body.adapterConfig.instructionsFilePath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { agentInstructionsService } from "../services/agent-instructions.js";
|
||||
|
||||
type TestAgent = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function makeTempDir(prefix: string) {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function makeAgent(adapterConfig: Record<string, unknown>): TestAgent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Agent 1",
|
||||
adapterConfig,
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent instructions service", () => {
|
||||
const originalPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = originalPaperclipHome;
|
||||
if (originalPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
else process.env.PAPERCLIP_INSTANCE_ID = originalPaperclipInstanceId;
|
||||
|
||||
await Promise.all([...cleanupDirs].map(async (dir) => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
cleanupDirs.delete(dir);
|
||||
}));
|
||||
});
|
||||
|
||||
it("copies the existing bundle into the managed root when switching to managed mode", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-");
|
||||
const externalRoot = await makeTempDir("paperclip-agent-instructions-external-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
cleanupDirs.add(externalRoot);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
await fs.writeFile(path.join(externalRoot, "AGENTS.md"), "# External Agent\n", "utf8");
|
||||
await fs.mkdir(path.join(externalRoot, "docs"), { recursive: true });
|
||||
await fs.writeFile(path.join(externalRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({
|
||||
instructionsBundleMode: "external",
|
||||
instructionsRootPath: externalRoot,
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: path.join(externalRoot, "AGENTS.md"),
|
||||
});
|
||||
|
||||
const result = await svc.updateBundle(agent, { mode: "managed" });
|
||||
|
||||
expect(result.bundle.mode).toBe("managed");
|
||||
expect(result.bundle.managedRootPath).toBe(
|
||||
path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
),
|
||||
);
|
||||
expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md", "docs/TOOLS.md"]);
|
||||
await expect(fs.readFile(path.join(result.bundle.managedRootPath, "AGENTS.md"), "utf8")).resolves.toBe("# External Agent\n");
|
||||
await expect(fs.readFile(path.join(result.bundle.managedRootPath, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n");
|
||||
});
|
||||
|
||||
it("creates the target entry file when switching to a new external root", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-");
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
const externalRoot = await makeTempDir("paperclip-agent-instructions-new-external-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
cleanupDirs.add(externalRoot);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
await fs.mkdir(managedRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: managedRoot,
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
|
||||
});
|
||||
|
||||
const result = await svc.updateBundle(agent, {
|
||||
mode: "external",
|
||||
rootPath: externalRoot,
|
||||
entryFile: "docs/AGENTS.md",
|
||||
});
|
||||
|
||||
expect(result.bundle.mode).toBe("external");
|
||||
expect(result.bundle.rootPath).toBe(externalRoot);
|
||||
await expect(fs.readFile(path.join(externalRoot, "docs", "AGENTS.md"), "utf8")).resolves.toBe("# Managed Agent\n");
|
||||
});
|
||||
|
||||
it("filters junk files, dependency bundles, and python caches from bundle listings and exports", async () => {
|
||||
const externalRoot = await makeTempDir("paperclip-agent-instructions-ignore-");
|
||||
cleanupDirs.add(externalRoot);
|
||||
|
||||
await fs.writeFile(path.join(externalRoot, "AGENTS.md"), "# External Agent\n", "utf8");
|
||||
await fs.writeFile(path.join(externalRoot, ".gitignore"), "node_modules/\n", "utf8");
|
||||
await fs.writeFile(path.join(externalRoot, ".DS_Store"), "junk", "utf8");
|
||||
await fs.mkdir(path.join(externalRoot, "docs"), { recursive: true });
|
||||
await fs.writeFile(path.join(externalRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
|
||||
await fs.writeFile(path.join(externalRoot, "docs", "module.pyc"), "compiled", "utf8");
|
||||
await fs.writeFile(path.join(externalRoot, "docs", "._TOOLS.md"), "appledouble", "utf8");
|
||||
await fs.mkdir(path.join(externalRoot, "node_modules", "pkg"), { recursive: true });
|
||||
await fs.writeFile(path.join(externalRoot, "node_modules", "pkg", "index.js"), "export {};\n", "utf8");
|
||||
await fs.mkdir(path.join(externalRoot, "python", "__pycache__"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(externalRoot, "python", "__pycache__", "module.cpython-313.pyc"),
|
||||
"compiled",
|
||||
"utf8",
|
||||
);
|
||||
await fs.mkdir(path.join(externalRoot, ".pytest_cache"), { recursive: true });
|
||||
await fs.writeFile(path.join(externalRoot, ".pytest_cache", "README.md"), "cache", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({
|
||||
instructionsBundleMode: "external",
|
||||
instructionsRootPath: externalRoot,
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: path.join(externalRoot, "AGENTS.md"),
|
||||
});
|
||||
|
||||
const bundle = await svc.getBundle(agent);
|
||||
const exported = await svc.exportFiles(agent);
|
||||
|
||||
expect(bundle.files.map((file) => file.path)).toEqual([".gitignore", "AGENTS.md", "docs/TOOLS.md"]);
|
||||
expect(Object.keys(exported.files).sort((left, right) => left.localeCompare(right))).toEqual([
|
||||
".gitignore",
|
||||
"AGENTS.md",
|
||||
"docs/TOOLS.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("recovers a managed bundle from disk when bundle config metadata is missing", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-recover-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
await fs.mkdir(managedRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Recovered Agent\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({});
|
||||
|
||||
const bundle = await svc.getBundle(agent);
|
||||
const exported = await svc.exportFiles(agent);
|
||||
|
||||
expect(bundle.mode).toBe("managed");
|
||||
expect(bundle.rootPath).toBe(managedRoot);
|
||||
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
|
||||
expect(exported.files).toEqual({ "AGENTS.md": "# Recovered Agent\n" });
|
||||
});
|
||||
|
||||
it("prefers the managed bundle on disk when managed metadata points at a stale root", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-stale-managed-");
|
||||
const staleRoot = await makeTempDir("paperclip-agent-instructions-stale-root-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
cleanupDirs.add(staleRoot);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
await fs.mkdir(managedRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: staleRoot,
|
||||
instructionsEntryFile: "docs/MISSING.md",
|
||||
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
|
||||
});
|
||||
|
||||
const bundle = await svc.getBundle(agent);
|
||||
const exported = await svc.exportFiles(agent);
|
||||
|
||||
expect(bundle.mode).toBe("managed");
|
||||
expect(bundle.rootPath).toBe(managedRoot);
|
||||
expect(bundle.entryFile).toBe("AGENTS.md");
|
||||
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
|
||||
expect(bundle.warnings).toEqual([
|
||||
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
|
||||
"Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
|
||||
]);
|
||||
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
|
||||
});
|
||||
|
||||
it("heals stale managed metadata when writing bundle files", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-write-");
|
||||
const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-write-stale-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
cleanupDirs.add(staleRoot);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true });
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: staleRoot,
|
||||
instructionsEntryFile: "docs/MISSING.md",
|
||||
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
|
||||
});
|
||||
|
||||
const result = await svc.writeFile(agent, "docs/TOOLS.md", "## Tools\n");
|
||||
|
||||
expect(result.adapterConfig).toMatchObject({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: managedRoot,
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
|
||||
});
|
||||
await expect(fs.readFile(path.join(managedRoot, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n");
|
||||
});
|
||||
|
||||
it("heals stale managed metadata when deleting bundle files", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-delete-");
|
||||
const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-delete-stale-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
cleanupDirs.add(staleRoot);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true });
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
|
||||
await fs.writeFile(path.join(managedRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: staleRoot,
|
||||
instructionsEntryFile: "docs/MISSING.md",
|
||||
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
|
||||
});
|
||||
|
||||
const result = await svc.deleteFile(agent, "docs/TOOLS.md");
|
||||
|
||||
expect(result.adapterConfig).toMatchObject({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: managedRoot,
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
|
||||
});
|
||||
await expect(fs.stat(path.join(managedRoot, "docs", "TOOLS.md"))).rejects.toThrow();
|
||||
expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
|
||||
});
|
||||
|
||||
it("recovers the managed bundle when stale root metadata is present but mode is missing", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-partial-managed-");
|
||||
const staleRoot = await makeTempDir("paperclip-agent-instructions-partial-root-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
cleanupDirs.add(staleRoot);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
await fs.mkdir(managedRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
|
||||
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({
|
||||
instructionsRootPath: staleRoot,
|
||||
instructionsEntryFile: "docs/MISSING.md",
|
||||
});
|
||||
|
||||
const bundle = await svc.getBundle(agent);
|
||||
const exported = await svc.exportFiles(agent);
|
||||
|
||||
expect(bundle.mode).toBe("managed");
|
||||
expect(bundle.rootPath).toBe(managedRoot);
|
||||
expect(bundle.entryFile).toBe("AGENTS.md");
|
||||
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
|
||||
expect(bundle.warnings).toEqual([
|
||||
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
|
||||
"Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
|
||||
]);
|
||||
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
const baseAgent = {
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Builder",
|
||||
urlKey: "builder",
|
||||
role: "engineer",
|
||||
title: "Builder",
|
||||
icon: null,
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updatePermissions: vi.fn(),
|
||||
getChainOfCommand: vi.fn(),
|
||||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
getMembership: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
listPrincipalGrants: vi.fn(),
|
||||
setPrincipalPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockApprovalService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBudgetService = vi.hoisted(() => ({
|
||||
upsertPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
listTaskSessions: vi.fn(),
|
||||
resetRuntimeSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||
linkManyForApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeAdapterConfigForPersistence: vi.fn(),
|
||||
resolveAdapterConfigForRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentInstructionsService = vi.hoisted(() => ({
|
||||
materializeManagedBundle: vi.fn(),
|
||||
}));
|
||||
const mockCompanySkillService = vi.hoisted(() => ({
|
||||
listRuntimeSkillEntries: vi.fn(),
|
||||
resolveRequestedSkillKeys: vi.fn(),
|
||||
}));
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
return {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue([{
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", agentRoutes(createDbStub() as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("agent permission routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgentService.getById.mockResolvedValue(baseAgent);
|
||||
mockAgentService.getChainOfCommand.mockResolvedValue([]);
|
||||
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent });
|
||||
mockAgentService.create.mockResolvedValue(baseAgent);
|
||||
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
|
||||
mockAccessService.getMembership.mockResolvedValue({
|
||||
id: "membership-1",
|
||||
companyId,
|
||||
principalType: "agent",
|
||||
principalId: agentId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
createdAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
});
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
|
||||
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
||||
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
||||
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
||||
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested);
|
||||
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
|
||||
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
|
||||
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
|
||||
bundle: null,
|
||||
adapterConfig: {
|
||||
...((agent.adapterConfig as Record<string, unknown> | undefined) ?? {}),
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: `/tmp/${String(agent.id)}/instructions`,
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: `/tmp/${String(agent.id)}/instructions/AGENTS.md`,
|
||||
promptTemplate: files["AGENTS.md"] ?? "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
||||
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(
|
||||
async (_companyId: string, requested: string[]) => requested,
|
||||
);
|
||||
mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
|
||||
mockSecretService.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ config }));
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("grants tasks:assign by default when board creates a new agent", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Builder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockAccessService.ensureMembership).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"agent",
|
||||
agentId,
|
||||
"member",
|
||||
"active",
|
||||
);
|
||||
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"agent",
|
||||
agentId,
|
||||
"tasks:assign",
|
||||
true,
|
||||
"board-user",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes explicit task assignment access on agent detail", async () => {
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
||||
{
|
||||
id: "grant-1",
|
||||
companyId,
|
||||
principalType: "agent",
|
||||
principalId: agentId,
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null,
|
||||
grantedByUserId: "board-user",
|
||||
createdAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/agents/${agentId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.access.canAssignTasks).toBe(true);
|
||||
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
|
||||
});
|
||||
|
||||
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
|
||||
mockAgentService.updatePermissions.mockResolvedValue({
|
||||
...baseAgent,
|
||||
permissions: { canCreateAgents: true },
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/agents/${agentId}/permissions`)
|
||||
.send({ canCreateAgents: true, canAssignTasks: false });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"agent",
|
||||
agentId,
|
||||
"tasks:assign",
|
||||
true,
|
||||
"board-user",
|
||||
);
|
||||
expect(res.body.access.canAssignTasks).toBe(true);
|
||||
expect(res.body.access.taskAssignSource).toBe("agent_creator");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasAgentShortnameCollision, deduplicateAgentName } from "../services/agents.ts";
|
||||
|
||||
describe("hasAgentShortnameCollision", () => {
|
||||
it("detects collisions by normalized shortname", () => {
|
||||
const collision = hasAgentShortnameCollision("Codex Coder", [
|
||||
{ id: "a1", name: "codex-coder", status: "idle" },
|
||||
]);
|
||||
expect(collision).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores terminated agents", () => {
|
||||
const collision = hasAgentShortnameCollision("Codex Coder", [
|
||||
{ id: "a1", name: "codex-coder", status: "terminated" },
|
||||
]);
|
||||
expect(collision).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the excluded agent id", () => {
|
||||
const collision = hasAgentShortnameCollision(
|
||||
"Codex Coder",
|
||||
[
|
||||
{ id: "a1", name: "codex-coder", status: "idle" },
|
||||
{ id: "a2", name: "other-agent", status: "idle" },
|
||||
],
|
||||
{ excludeAgentId: "a1" },
|
||||
);
|
||||
expect(collision).toBe(false);
|
||||
});
|
||||
|
||||
it("does not collide when candidate has no shortname", () => {
|
||||
const collision = hasAgentShortnameCollision("!!!", [
|
||||
{ id: "a1", name: "codex-coder", status: "idle" },
|
||||
]);
|
||||
expect(collision).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deduplicateAgentName", () => {
|
||||
it("returns original name when no collision", () => {
|
||||
const name = deduplicateAgentName("OpenClaw", [
|
||||
{ id: "a1", name: "other-agent", status: "idle" },
|
||||
]);
|
||||
expect(name).toBe("OpenClaw");
|
||||
});
|
||||
|
||||
it("appends suffix when name collides", () => {
|
||||
const name = deduplicateAgentName("OpenClaw", [
|
||||
{ id: "a1", name: "openclaw", status: "idle" },
|
||||
]);
|
||||
expect(name).toBe("OpenClaw 2");
|
||||
});
|
||||
|
||||
it("increments suffix until unique", () => {
|
||||
const name = deduplicateAgentName("OpenClaw", [
|
||||
{ id: "a1", name: "openclaw", status: "idle" },
|
||||
{ id: "a2", name: "openclaw-2", status: "idle" },
|
||||
{ id: "a3", name: "openclaw-3", status: "idle" },
|
||||
]);
|
||||
expect(name).toBe("OpenClaw 4");
|
||||
});
|
||||
|
||||
it("ignores terminated agents for collision", () => {
|
||||
const name = deduplicateAgentName("OpenClaw", [
|
||||
{ id: "a1", name: "openclaw", status: "terminated" },
|
||||
]);
|
||||
expect(name).toBe("OpenClaw");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
agentSkillEntrySchema,
|
||||
agentSkillSnapshotSchema,
|
||||
} from "@paperclipai/shared/validators/adapter-skills";
|
||||
|
||||
describe("agent skill contract", () => {
|
||||
it("accepts optional provenance metadata on skill entries", () => {
|
||||
expect(agentSkillEntrySchema.parse({
|
||||
key: "crack-python",
|
||||
runtimeName: "crack-python",
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
origin: "user_installed",
|
||||
originLabel: "User-installed",
|
||||
locationLabel: "~/.claude/skills",
|
||||
readOnly: true,
|
||||
detail: "Installed outside Paperclip management.",
|
||||
})).toMatchObject({
|
||||
origin: "user_installed",
|
||||
locationLabel: "~/.claude/skills",
|
||||
readOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("remains backward compatible with snapshots that omit provenance metadata", () => {
|
||||
expect(agentSkillSnapshotSchema.parse({
|
||||
adapterType: "claude_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: [],
|
||||
entries: [{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
runtimeName: "paperclip",
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "configured",
|
||||
}],
|
||||
warnings: [],
|
||||
})).toMatchObject({
|
||||
adapterType: "claude_local",
|
||||
entries: [{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
state: "configured",
|
||||
}],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
getMembership: vi.fn(),
|
||||
listPrincipalGrants: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
setPrincipalPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockApprovalService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
}));
|
||||
const mockBudgetService = vi.hoisted(() => ({}));
|
||||
const mockHeartbeatService = vi.hoisted(() => ({}));
|
||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||
linkManyForApproval: vi.fn(),
|
||||
}));
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockAgentInstructionsService = vi.hoisted(() => ({
|
||||
getBundle: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
updateBundle: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
exportFiles: vi.fn(),
|
||||
ensureManagedBundle: vi.fn(),
|
||||
materializeManagedBundle: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCompanySkillService = vi.hoisted(() => ({
|
||||
listRuntimeSkillEntries: vi.fn(),
|
||||
resolveRequestedSkillKeys: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
resolveAdapterConfigForRuntime: vi.fn(),
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockAdapter = vi.hoisted(() => ({
|
||||
listSkills: vi.fn(),
|
||||
syncSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueService: () => ({}),
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(() => mockAdapter),
|
||||
listAdapterModels: vi.fn(),
|
||||
}));
|
||||
|
||||
function createDb(requireBoardApprovalForNewAgents = false) {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(async () => [
|
||||
{
|
||||
id: "company-1",
|
||||
requireBoardApprovalForNewAgents,
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(db: Record<string, unknown> = createDb()) {
|
||||
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(db as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeAgent(adapterType: string) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
name: "Agent",
|
||||
role: "engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType,
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent skill routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgentService.resolveByReference.mockResolvedValue({
|
||||
ambiguous: false,
|
||||
agent: makeAgent("claude_local"),
|
||||
});
|
||||
mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValue({ config: { env: {} } });
|
||||
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([
|
||||
{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source: "/tmp/paperclip",
|
||||
required: true,
|
||||
requiredReason: "required",
|
||||
},
|
||||
]);
|
||||
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(
|
||||
async (_companyId: string, requested: string[]) =>
|
||||
requested.map((value) =>
|
||||
value === "paperclip"
|
||||
? "paperclipai/paperclip/paperclip"
|
||||
: value,
|
||||
),
|
||||
);
|
||||
mockAdapter.listSkills.mockResolvedValue({
|
||||
adapterType: "claude_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockAdapter.syncSkills.mockResolvedValue({
|
||||
adapterType: "claude_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeAgent("claude_local"),
|
||||
adapterConfig: patch.adapterConfig ?? {},
|
||||
}));
|
||||
mockAgentService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
...makeAgent(String(input.adapterType ?? "claude_local")),
|
||||
...input,
|
||||
adapterConfig: input.adapterConfig ?? {},
|
||||
runtimeConfig: input.runtimeConfig ?? {},
|
||||
budgetMonthlyCents: Number(input.budgetMonthlyCents ?? 0),
|
||||
permissions: null,
|
||||
}));
|
||||
mockApprovalService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
payload: input.payload ?? {},
|
||||
}));
|
||||
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
|
||||
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
|
||||
bundle: null,
|
||||
adapterConfig: {
|
||||
...((agent.adapterConfig as Record<string, unknown> | undefined) ?? {}),
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: `/tmp/${String(agent.id)}/instructions`,
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: `/tmp/${String(agent.id)}/instructions/AGENTS.md`,
|
||||
promptTemplate: files["AGENTS.md"] ?? "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.hasPermission.mockResolvedValue(true);
|
||||
mockAccessService.getMembership.mockResolvedValue(null);
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
|
||||
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
||||
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("skips runtime materialization when listing Claude skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
|
||||
|
||||
const res = await request(createApp())
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
|
||||
materializeMissing: false,
|
||||
});
|
||||
expect(mockAdapter.listSkills).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
adapterType: "claude_local",
|
||||
config: expect.objectContaining({
|
||||
paperclipRuntimeSkills: expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps runtime materialization for persistent skill adapters", async () => {
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent("codex_local"));
|
||||
mockAdapter.listSkills.mockResolvedValue({
|
||||
adapterType: "codex_local",
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
|
||||
materializeMissing: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips runtime materialization when syncing Claude skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
|
||||
.send({ desiredSkills: ["paperclipai/paperclip/paperclip"] });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
|
||||
materializeMissing: false,
|
||||
});
|
||||
expect(mockAdapter.syncSkills).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("canonicalizes desired skill references before syncing", async () => {
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
|
||||
.send({ desiredSkills: ["paperclip"] });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
paperclipSkillSync: expect.objectContaining({
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists canonical desired skills when creating an agent directly", async () => {
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
desiredSkills: ["paperclip"],
|
||||
adapterConfig: {},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
paperclipSkillSync: expect.objectContaining({
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("materializes a managed AGENTS.md for directly created local agents", async () => {
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
promptTemplate: "You are QA.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
adapterType: "claude_local",
|
||||
}),
|
||||
{ "AGENTS.md": "You are QA." },
|
||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||
);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).not.toMatchObject({
|
||||
adapterConfig: expect.objectContaining({
|
||||
promptTemplate: expect.anything(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("materializes the bundled CEO instruction set for default CEO agents", async () => {
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
role: "ceo",
|
||||
adapterType: "claude_local",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
"AGENTS.md": expect.stringContaining("You are the CEO."),
|
||||
"HEARTBEAT.md": expect.stringContaining("CEO Heartbeat Checklist"),
|
||||
"SOUL.md": expect.stringContaining("CEO Persona"),
|
||||
"TOOLS.md": expect.stringContaining("# Tools"),
|
||||
}),
|
||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("materializes the bundled default instruction set for non-CEO agents with no prompt template", async () => {
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "Engineer",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
"AGENTS.md": expect.stringContaining("Keep the work moving until it's done."),
|
||||
}),
|
||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("includes canonical desired skills in hire approvals", async () => {
|
||||
const db = createDb(true);
|
||||
|
||||
const res = await request(createApp(db))
|
||||
.post("/api/companies/company-1/agent-hires")
|
||||
.send({
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
desiredSkills: ["paperclip"],
|
||||
adapterConfig: {},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
|
||||
expect(mockApprovalService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
requestedConfigurationSnapshot: expect.objectContaining({
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses managed AGENTS config in hire approval payloads", async () => {
|
||||
const res = await request(createApp(createDb(true)))
|
||||
.post("/api/companies/company-1/agent-hires")
|
||||
.send({
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
promptTemplate: "You are QA.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockApprovalService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const approvalInput = mockApprovalService.create.mock.calls.at(-1)?.[1] as
|
||||
| { payload?: { adapterConfig?: Record<string, unknown> } }
|
||||
| undefined;
|
||||
expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveViteHmrPort } from "../app.ts";
|
||||
|
||||
describe("resolveViteHmrPort", () => {
|
||||
it("uses serverPort + 10000 when the result stays in range", () => {
|
||||
expect(resolveViteHmrPort(3100)).toBe(13_100);
|
||||
expect(resolveViteHmrPort(55_535)).toBe(65_535);
|
||||
});
|
||||
|
||||
it("falls back below the server port when adding 10000 would overflow", () => {
|
||||
expect(resolveViteHmrPort(55_536)).toBe(45_536);
|
||||
expect(resolveViteHmrPort(63_000)).toBe(53_000);
|
||||
});
|
||||
|
||||
it("never returns a privileged or invalid port", () => {
|
||||
expect(resolveViteHmrPort(65_535)).toBe(55_535);
|
||||
expect(resolveViteHmrPort(9_000)).toBe(19_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalRoutes } from "../routes/approvals.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockApprovalService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
approve: vi.fn(),
|
||||
reject: vi.fn(),
|
||||
requestRevision: vi.fn(),
|
||||
resubmit: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||
listIssuesForApproval: vi.fn(),
|
||||
linkManyForApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeHireApprovalPayloadForPersistence: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
approvalService: () => mockApprovalService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", approvalRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("approval routes idempotent retries", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
|
||||
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("does not emit duplicate approval side effects when approve is already resolved", async () => {
|
||||
mockApprovalService.approve.mockResolvedValue({
|
||||
approval: {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "hire_agent",
|
||||
status: "approved",
|
||||
payload: {},
|
||||
requestedByAgentId: "agent-1",
|
||||
},
|
||||
applied: false,
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/approvals/approval-1/approve")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueApprovalService.listIssuesForApproval).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit duplicate rejection logs when reject is already resolved", async () => {
|
||||
mockApprovalService.reject.mockResolvedValue({
|
||||
approval: {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "hire_agent",
|
||||
status: "rejected",
|
||||
payload: {},
|
||||
},
|
||||
applied: false,
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/approvals/approval-1/reject")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalService } from "../services/approvals.ts";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
activatePendingApproval: vi.fn(),
|
||||
create: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNotifyHireApproved = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/agents.js", () => ({
|
||||
agentService: vi.fn(() => mockAgentService),
|
||||
}));
|
||||
|
||||
vi.mock("../services/hire-hook.js", () => ({
|
||||
notifyHireApproved: mockNotifyHireApproved,
|
||||
}));
|
||||
|
||||
type ApprovalRecord = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
type: string;
|
||||
status: string;
|
||||
payload: Record<string, unknown>;
|
||||
requestedByAgentId: string | null;
|
||||
};
|
||||
|
||||
function createApproval(status: string): ApprovalRecord {
|
||||
return {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "hire_agent",
|
||||
status,
|
||||
payload: { agentId: "agent-1" },
|
||||
requestedByAgentId: "requester-1",
|
||||
};
|
||||
}
|
||||
|
||||
function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) {
|
||||
const pendingSelectResults = [...selectResults];
|
||||
const selectWhere = vi.fn(async () => pendingSelectResults.shift() ?? []);
|
||||
const from = vi.fn(() => ({ where: selectWhere }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
|
||||
const returning = vi.fn(async () => updateResults);
|
||||
const updateWhere = vi.fn(() => ({ returning }));
|
||||
const set = vi.fn(() => ({ where: updateWhere }));
|
||||
const update = vi.fn(() => ({ set }));
|
||||
|
||||
return {
|
||||
db: { select, update },
|
||||
selectWhere,
|
||||
returning,
|
||||
};
|
||||
}
|
||||
|
||||
describe("approvalService resolution idempotency", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgentService.activatePendingApproval.mockResolvedValue(undefined);
|
||||
mockAgentService.create.mockResolvedValue({ id: "agent-1" });
|
||||
mockAgentService.terminate.mockResolvedValue(undefined);
|
||||
mockNotifyHireApproved.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("treats repeated approve retries as no-ops after another worker resolves the approval", async () => {
|
||||
const dbStub = createDbStub(
|
||||
[[createApproval("pending")], [createApproval("approved")]],
|
||||
[],
|
||||
);
|
||||
|
||||
const svc = approvalService(dbStub.db as any);
|
||||
const result = await svc.approve("approval-1", "board", "ship it");
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.approval.status).toBe("approved");
|
||||
expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled();
|
||||
expect(mockNotifyHireApproved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats repeated reject retries as no-ops after another worker resolves the approval", async () => {
|
||||
const dbStub = createDbStub(
|
||||
[[createApproval("pending")], [createApproval("rejected")]],
|
||||
[],
|
||||
);
|
||||
|
||||
const svc = approvalService(dbStub.db as any);
|
||||
const result = await svc.reject("approval-1", "board", "not now");
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.approval.status).toBe("rejected");
|
||||
expect(mockAgentService.terminate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still performs side effects when the resolution update is newly applied", async () => {
|
||||
const approved = createApproval("approved");
|
||||
const dbStub = createDbStub([[createApproval("pending")]], [approved]);
|
||||
|
||||
const svc = approvalService(dbStub.db as any);
|
||||
const result = await svc.approve("approval-1", "board", "ship it");
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1");
|
||||
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import { assetRoutes } from "../routes/assets.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
||||
const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() => ({
|
||||
createAssetMock: vi.fn(),
|
||||
getAssetByIdMock: vi.fn(),
|
||||
logActivityMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
assetService: vi.fn(() => ({
|
||||
create: createAssetMock,
|
||||
getById: getAssetByIdMock,
|
||||
})),
|
||||
logActivity: logActivityMock,
|
||||
}));
|
||||
|
||||
function createAsset() {
|
||||
const now = new Date("2026-01-01T00:00:00.000Z");
|
||||
return {
|
||||
id: "asset-1",
|
||||
companyId: "company-1",
|
||||
provider: "local",
|
||||
objectKey: "assets/abc",
|
||||
contentType: "image/png",
|
||||
byteSize: 40,
|
||||
sha256: "sha256-sample",
|
||||
originalFilename: "logo.png",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function createStorageService(contentType = "image/png"): StorageService {
|
||||
const putFile: StorageService["putFile"] = vi.fn(async (input: {
|
||||
companyId: string;
|
||||
namespace: string;
|
||||
originalFilename: string | null;
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
}) => {
|
||||
return {
|
||||
provider: "local_disk" as const,
|
||||
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
|
||||
contentType: contentType || input.contentType,
|
||||
byteSize: input.body.length,
|
||||
sha256: "sha256-sample",
|
||||
originalFilename: input.originalFilename,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
provider: "local_disk" as const,
|
||||
putFile,
|
||||
getObject: vi.fn(),
|
||||
headObject: vi.fn(),
|
||||
deleteObject: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(storage: ReturnType<typeof createStorageService>) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
userId: "user-1",
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", assetRoutes({} as any, storage));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
afterEach(() => {
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
});
|
||||
|
||||
it("accepts PNG image uploads and returns an asset path", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "goals")
|
||||
.attach("file", Buffer.from("png"), "logo.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(createAssetMock).toHaveBeenCalledTimes(1);
|
||||
expect(png.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/goals",
|
||||
originalFilename: "logo.png",
|
||||
contentType: "image/png",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
|
||||
it("allows supported non-image attachments outside the company logo flow", async () => {
|
||||
const text = createStorageService("text/plain");
|
||||
const app = createApp(text);
|
||||
|
||||
createAssetMock.mockResolvedValue({
|
||||
...createAsset(),
|
||||
contentType: "text/plain",
|
||||
originalFilename: "note.txt",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "issues/drafts")
|
||||
.attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(text.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/issues/drafts",
|
||||
originalFilename: "note.txt",
|
||||
contentType: "text/plain",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/companies/:companyId/logo", () => {
|
||||
afterEach(() => {
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
});
|
||||
|
||||
it("accepts PNG logo uploads and returns an asset path", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("png"), "logo.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(createAssetMock).toHaveBeenCalledTimes(1);
|
||||
expect(png.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/companies",
|
||||
originalFilename: "logo.png",
|
||||
contentType: "image/png",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes SVG logo uploads before storing them", async () => {
|
||||
const svg = createStorageService("image/svg+xml");
|
||||
const app = createApp(svg);
|
||||
|
||||
createAssetMock.mockResolvedValue({
|
||||
...createAsset(),
|
||||
contentType: "image/svg+xml",
|
||||
originalFilename: "logo.svg",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach(
|
||||
"file",
|
||||
Buffer.from(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' onload='alert(1)'><script>alert(1)</script><a href='https://evil.example/'><circle cx='12' cy='12' r='10'/></a></svg>",
|
||||
),
|
||||
"logo.svg",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(svg.putFile).toHaveBeenCalledTimes(1);
|
||||
const stored = (svg.putFile as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
|
||||
expect(stored.contentType).toBe("image/svg+xml");
|
||||
expect(stored.originalFilename).toBe("logo.svg");
|
||||
const body = stored.body.toString("utf8");
|
||||
expect(body).toContain("<svg");
|
||||
expect(body).toContain("<circle");
|
||||
expect(body).not.toContain("<script");
|
||||
expect(body).not.toContain("onload=");
|
||||
expect(body).not.toContain("https://evil.example/");
|
||||
});
|
||||
|
||||
it("allows logo uploads within the general attachment limit", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const file = Buffer.alloc(150 * 1024, "a");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "within-limit.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("rejects logo files larger than the general attachment limit", async () => {
|
||||
const app = createApp(createStorageService());
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const file = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1, "a");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "too-large.png");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe(`Image exceeds ${MAX_ATTACHMENT_BYTES} bytes`);
|
||||
});
|
||||
|
||||
it("rejects unsupported image types", async () => {
|
||||
const app = createApp(createStorageService("text/plain"));
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not an image"), "note.txt");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Unsupported image type: text/plain");
|
||||
expect(createAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects SVG image uploads that cannot be sanitized", async () => {
|
||||
const app = createApp(createStorageService("image/svg+xml"));
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not actually svg"), "logo.svg");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("SVG could not be sanitized");
|
||||
expect(createAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseAllowedTypes,
|
||||
matchesContentType,
|
||||
DEFAULT_ALLOWED_TYPES,
|
||||
} from "../attachment-types.js";
|
||||
|
||||
describe("parseAllowedTypes", () => {
|
||||
it("returns default image types when input is undefined", () => {
|
||||
expect(parseAllowedTypes(undefined)).toEqual([...DEFAULT_ALLOWED_TYPES]);
|
||||
});
|
||||
|
||||
it("returns default image types when input is empty string", () => {
|
||||
expect(parseAllowedTypes("")).toEqual([...DEFAULT_ALLOWED_TYPES]);
|
||||
});
|
||||
|
||||
it("parses comma-separated types", () => {
|
||||
expect(parseAllowedTypes("image/*,application/pdf")).toEqual([
|
||||
"image/*",
|
||||
"application/pdf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(parseAllowedTypes(" image/png , application/pdf ")).toEqual([
|
||||
"image/png",
|
||||
"application/pdf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lowercases entries", () => {
|
||||
expect(parseAllowedTypes("Application/PDF")).toEqual(["application/pdf"]);
|
||||
});
|
||||
|
||||
it("filters empty segments", () => {
|
||||
expect(parseAllowedTypes("image/png,,application/pdf,")).toEqual([
|
||||
"image/png",
|
||||
"application/pdf",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesContentType", () => {
|
||||
it("matches exact types", () => {
|
||||
const patterns = ["application/pdf", "image/png"];
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||
expect(matchesContentType("text/plain", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches /* wildcard patterns", () => {
|
||||
const patterns = ["image/*"];
|
||||
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||
expect(matchesContentType("image/jpeg", patterns)).toBe(true);
|
||||
expect(matchesContentType("image/svg+xml", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches .* wildcard patterns", () => {
|
||||
const patterns = ["application/vnd.openxmlformats-officedocument.*"];
|
||||
expect(
|
||||
matchesContentType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
patterns,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesContentType(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
patterns,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const patterns = ["application/pdf"];
|
||||
expect(matchesContentType("APPLICATION/PDF", patterns)).toBe(true);
|
||||
expect(matchesContentType("Application/Pdf", patterns)).toBe(true);
|
||||
});
|
||||
|
||||
it("combines exact and wildcard patterns", () => {
|
||||
const patterns = ["image/*", "application/pdf", "text/*"];
|
||||
expect(matchesContentType("image/webp", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||
expect(matchesContentType("text/csv", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/zip", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles plain * as allow-all wildcard", () => {
|
||||
const patterns = ["*"];
|
||||
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||
expect(matchesContentType("text/plain", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/zip", patterns)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
|
||||
|
||||
function createApp(
|
||||
actorType: "board" | "agent",
|
||||
boardSource: "session" | "local_implicit" | "board_key" = "session",
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actorType === "board"
|
||||
? { type: "board", userId: "board", source: boardSource }
|
||||
: { type: "agent", agentId: "agent-1" };
|
||||
next();
|
||||
});
|
||||
app.use(boardMutationGuard());
|
||||
app.post("/mutate", (_req, res) => {
|
||||
res.status(204).end();
|
||||
});
|
||||
app.get("/read", (_req, res) => {
|
||||
res.status(204).end();
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("boardMutationGuard", () => {
|
||||
it("allows safe methods for board actor", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app).get("/read");
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("blocks board mutations without trusted origin", () => {
|
||||
const middleware = boardMutationGuard();
|
||||
const req = {
|
||||
method: "POST",
|
||||
actor: { type: "board", userId: "board", source: "session" },
|
||||
header: () => undefined,
|
||||
} as any;
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
} as any;
|
||||
const next = vi.fn();
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: "Board mutation requires trusted browser origin",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local implicit board mutations without origin", async () => {
|
||||
const app = createApp("board", "local_implicit");
|
||||
const res = await request(app).post("/mutate").send({ ok: true });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("allows board bearer-key mutations without origin", async () => {
|
||||
const app = createApp("board", "board_key");
|
||||
const res = await request(app).post("/mutate").send({ ok: true });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("allows board mutations from trusted origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app)
|
||||
.post("/mutate")
|
||||
.set("Origin", "http://localhost:3100")
|
||||
.send({ ok: true });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("allows board mutations from trusted referer origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app)
|
||||
.post("/mutate")
|
||||
.set("Referer", "http://localhost:3100/issues/abc")
|
||||
.send({ ok: true });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("does not block authenticated agent mutations", async () => {
|
||||
const middleware = boardMutationGuard();
|
||||
const req = {
|
||||
method: "POST",
|
||||
actor: { type: "agent", agentId: "agent-1" },
|
||||
header: () => undefined,
|
||||
} as any;
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
} as any;
|
||||
const next = vi.fn();
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { budgetService } from "../services/budgets.ts";
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
type SelectResult = unknown[];
|
||||
|
||||
function createDbStub(selectResults: SelectResult[]) {
|
||||
const pendingSelects = [...selectResults];
|
||||
const selectWhere = vi.fn(async () => pendingSelects.shift() ?? []);
|
||||
const selectThen = vi.fn((resolve: (value: unknown[]) => unknown) => Promise.resolve(resolve(pendingSelects.shift() ?? [])));
|
||||
const selectOrderBy = vi.fn(async () => pendingSelects.shift() ?? []);
|
||||
const selectFrom = vi.fn(() => ({
|
||||
where: selectWhere,
|
||||
then: selectThen,
|
||||
orderBy: selectOrderBy,
|
||||
}));
|
||||
const select = vi.fn(() => ({
|
||||
from: selectFrom,
|
||||
}));
|
||||
|
||||
const insertValues = vi.fn();
|
||||
const insertReturning = vi.fn(async () => pendingInserts.shift() ?? []);
|
||||
const insert = vi.fn(() => ({
|
||||
values: insertValues.mockImplementation(() => ({
|
||||
returning: insertReturning,
|
||||
})),
|
||||
}));
|
||||
|
||||
const updateSet = vi.fn();
|
||||
const updateWhere = vi.fn(async () => pendingUpdates.shift() ?? []);
|
||||
const update = vi.fn(() => ({
|
||||
set: updateSet.mockImplementation(() => ({
|
||||
where: updateWhere,
|
||||
})),
|
||||
}));
|
||||
|
||||
const pendingInserts: unknown[][] = [];
|
||||
const pendingUpdates: unknown[][] = [];
|
||||
|
||||
return {
|
||||
db: {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
},
|
||||
queueInsert: (rows: unknown[]) => {
|
||||
pendingInserts.push(rows);
|
||||
},
|
||||
queueUpdate: (rows: unknown[] = []) => {
|
||||
pendingUpdates.push(rows);
|
||||
},
|
||||
selectWhere,
|
||||
insertValues,
|
||||
updateSet,
|
||||
};
|
||||
}
|
||||
|
||||
describe("budgetService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a hard-stop incident and pauses an agent when spend exceeds a budget", async () => {
|
||||
const policy = {
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const dbStub = createDbStub([
|
||||
[policy],
|
||||
[{ total: 150 }],
|
||||
[],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
status: "running",
|
||||
pauseReason: null,
|
||||
}],
|
||||
]);
|
||||
|
||||
dbStub.queueInsert([{
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
status: "pending",
|
||||
}]);
|
||||
dbStub.queueInsert([{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
approvalId: "approval-1",
|
||||
}]);
|
||||
dbStub.queueUpdate([]);
|
||||
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const service = budgetService(dbStub.db as any, { cancelWorkForScope });
|
||||
await service.evaluateCostEvent({
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
projectId: null,
|
||||
} as any);
|
||||
|
||||
expect(dbStub.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
type: "budget_override_required",
|
||||
status: "pending",
|
||||
}),
|
||||
);
|
||||
expect(dbStub.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
thresholdType: "hard",
|
||||
amountLimit: 100,
|
||||
amountObserved: 150,
|
||||
approvalId: "approval-1",
|
||||
}),
|
||||
);
|
||||
expect(dbStub.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "budget.hard_threshold_crossed",
|
||||
entityId: "incident-1",
|
||||
}),
|
||||
);
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks new work when an agent hard-stop remains exceeded even if the agent is not paused yet", async () => {
|
||||
const agentPolicy = {
|
||||
id: "policy-agent-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
status: "running",
|
||||
pauseReason: null,
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
}],
|
||||
[{
|
||||
status: "active",
|
||||
name: "Paperclip",
|
||||
}],
|
||||
[],
|
||||
[agentPolicy],
|
||||
[{ total: 120 }],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
const block = await service.getInvocationBlock("company-1", "agent-1");
|
||||
|
||||
expect(block).toEqual({
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
scopeName: "Budget Agent",
|
||||
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a budget-owned company pause distinctly from a manual pause", async () => {
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
}],
|
||||
[{
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
name: "Paperclip",
|
||||
}],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
const block = await service.getInvocationBlock("company-1", "agent-1");
|
||||
|
||||
expect(block).toEqual({
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
scopeName: "Paperclip",
|
||||
reason: "Company is paused because its budget hard-stop was reached.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses live observed spend when raising a budget incident", async () => {
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
amountObserved: 120,
|
||||
approvalId: "approval-1",
|
||||
}],
|
||||
[{
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
}],
|
||||
[{ total: 150 }],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
|
||||
await expect(
|
||||
service.resolveIncident(
|
||||
"company-1",
|
||||
"incident-1",
|
||||
{ action: "raise_budget_and_resume", amount: 140 },
|
||||
"board-user",
|
||||
),
|
||||
).rejects.toThrow("New budget must exceed current observed spend");
|
||||
});
|
||||
|
||||
it("syncs company monthly budget when raising and resuming a company incident", async () => {
|
||||
const now = new Date();
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
windowStart: now,
|
||||
windowEnd: now,
|
||||
thresholdType: "hard",
|
||||
amountLimit: 100,
|
||||
amountObserved: 120,
|
||||
status: "open",
|
||||
approvalId: "approval-1",
|
||||
resolvedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
[{
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
}],
|
||||
[{ total: 120 }],
|
||||
[{ id: "approval-1", status: "approved" }],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
name: "Paperclip",
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: now,
|
||||
}],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
await service.resolveIncident(
|
||||
"company-1",
|
||||
"incident-1",
|
||||
{ action: "raise_budget_and_resume", amount: 175 },
|
||||
"board-user",
|
||||
);
|
||||
|
||||
expect(dbStub.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
budgetMonthlyCents: 175,
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { testEnvironment } from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
const ORIGINAL_ANTHROPIC = process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_ANTHROPIC === undefined) {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
} else {
|
||||
process.env.ANTHROPIC_API_KEY = ORIGINAL_ANTHROPIC;
|
||||
}
|
||||
});
|
||||
|
||||
describe("claude_local environment diagnostics", () => {
|
||||
it("returns a warning (not an error) when ANTHROPIC_API_KEY is set in host environment", async () => {
|
||||
process.env.ANTHROPIC_API_KEY = "sk-test-host";
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("warn");
|
||||
expect(
|
||||
result.checks.some(
|
||||
(check) =>
|
||||
check.code === "claude_anthropic_api_key_overrides_subscription" &&
|
||||
check.level === "warn",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.checks.some((check) => check.level === "error")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a warning (not an error) when ANTHROPIC_API_KEY is set in adapter env", async () => {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "sk-test-config",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("warn");
|
||||
expect(
|
||||
result.checks.some(
|
||||
(check) =>
|
||||
check.code === "claude_anthropic_api_key_overrides_subscription" &&
|
||||
check.level === "warn",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.checks.some((check) => check.level === "error")).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a missing working directory when cwd is absolute", async () => {
|
||||
const cwd = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-claude-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
"workspace",
|
||||
);
|
||||
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "claude_cwd_valid")).toBe(true);
|
||||
expect(result.checks.some((check) => check.level === "error")).toBe(false);
|
||||
const stats = await fs.stat(cwd);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isClaudeMaxTurnsResult } from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
describe("claude_local max-turn detection", () => {
|
||||
it("detects max-turn exhaustion by subtype", () => {
|
||||
expect(
|
||||
isClaudeMaxTurnsResult({
|
||||
subtype: "error_max_turns",
|
||||
result: "Reached max turns",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects max-turn exhaustion by stop_reason", () => {
|
||||
expect(
|
||||
isClaudeMaxTurnsResult({
|
||||
stop_reason: "max_turns",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-max-turn results", () => {
|
||||
expect(
|
||||
isClaudeMaxTurnsResult({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listClaudeSkills,
|
||||
syncClaudeSkills,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function createSkillDir(root: string, name: string) {
|
||||
const skillDir = path.join(root, name);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
describe("claude local skill sync", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => {
|
||||
const snapshot = await listClaudeSkills({
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(snapshot.mode).toBe("ephemeral");
|
||||
expect(snapshot.supported).toBe(true);
|
||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
});
|
||||
|
||||
it("respects an explicit desired skill list without mutating a persistent home", async () => {
|
||||
const snapshot = await syncClaudeSkills({
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
}, [paperclipKey]);
|
||||
|
||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("configured");
|
||||
});
|
||||
|
||||
it("normalizes legacy flat Paperclip skill refs to canonical keys", async () => {
|
||||
const snapshot = await listClaudeSkills({
|
||||
agentId: "agent-3",
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.warnings).toEqual([]);
|
||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||
expect(snapshot.desiredSkills).not.toContain("paperclip");
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows host-level user-installed Claude skills as read-only external entries", async () => {
|
||||
const home = await makeTempDir("paperclip-claude-user-skills-");
|
||||
cleanupDirs.add(home);
|
||||
await createSkillDir(path.join(home, ".claude", "skills"), "crack-python");
|
||||
|
||||
const snapshot = await listClaudeSkills({
|
||||
agentId: "agent-4",
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||
key: "crack-python",
|
||||
runtimeName: "crack-python",
|
||||
state: "external",
|
||||
managed: false,
|
||||
origin: "user_installed",
|
||||
originLabel: "User-installed",
|
||||
locationLabel: "~/.claude/skills",
|
||||
readOnly: true,
|
||||
detail: "Installed outside Paperclip management in the Claude skills home.",
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBoardAuthService = vi.hoisted(() => ({
|
||||
createCliAuthChallenge: vi.fn(),
|
||||
describeCliAuthChallenge: vi.fn(),
|
||||
approveCliAuthChallenge: vi.fn(),
|
||||
cancelCliAuthChallenge: vi.fn(),
|
||||
resolveBoardAccess: vi.fn(),
|
||||
resolveBoardActivityCompanyIds: vi.fn(),
|
||||
assertCurrentBoardKey: vi.fn(),
|
||||
revokeBoardApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
deduplicateAgentName: vi.fn((name: string) => name),
|
||||
}));
|
||||
|
||||
function createApp(actor: any) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
return import("../routes/access.js").then(({ accessRoutes }) =>
|
||||
import("../middleware/index.js").then(({ errorHandler }) => {
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes({} as any, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe("cli auth routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a CLI auth challenge with approval metadata", async () => {
|
||||
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
|
||||
challenge: {
|
||||
id: "challenge-1",
|
||||
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
|
||||
},
|
||||
challengeSecret: "pcp_cli_auth_secret",
|
||||
pendingBoardToken: "pcp_board_token",
|
||||
});
|
||||
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const res = await request(app)
|
||||
.post("/api/cli-auth/challenges")
|
||||
.send({
|
||||
command: "paperclipai company import",
|
||||
clientName: "paperclipai cli",
|
||||
requestedAccess: "board",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "challenge-1",
|
||||
token: "pcp_cli_auth_secret",
|
||||
boardApiToken: "pcp_board_token",
|
||||
approvalPath: "/cli-auth/challenge-1?token=pcp_cli_auth_secret",
|
||||
pollPath: "/cli-auth/challenges/challenge-1",
|
||||
expiresAt: "2026-03-23T13:00:00.000Z",
|
||||
});
|
||||
expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret");
|
||||
});
|
||||
|
||||
it("marks challenge status as requiring sign-in for anonymous viewers", async () => {
|
||||
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
|
||||
id: "challenge-1",
|
||||
status: "pending",
|
||||
command: "paperclipai company import",
|
||||
clientName: "paperclipai cli",
|
||||
requestedAccess: "board",
|
||||
requestedCompanyId: null,
|
||||
requestedCompanyName: null,
|
||||
approvedAt: null,
|
||||
cancelledAt: null,
|
||||
expiresAt: "2026-03-23T13:00:00.000Z",
|
||||
approvedByUser: null,
|
||||
});
|
||||
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const res = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.requiresSignIn).toBe(true);
|
||||
expect(res.body.canApprove).toBe(false);
|
||||
});
|
||||
|
||||
it("approves a CLI auth challenge for a signed-in board user", async () => {
|
||||
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
|
||||
status: "approved",
|
||||
challenge: {
|
||||
id: "challenge-1",
|
||||
boardApiKeyId: "board-key-1",
|
||||
requestedAccess: "board",
|
||||
requestedCompanyId: "company-1",
|
||||
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
mockBoardAuthService.resolveBoardAccess.mockResolvedValue({
|
||||
user: { id: "user-1", name: "User One", email: "user@example.com" },
|
||||
companyIds: ["company-1"],
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
const res = await request(app)
|
||||
.post("/api/cli-auth/challenges/challenge-1/approve")
|
||||
.send({ token: "pcp_cli_auth_secret" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
approved: true,
|
||||
status: "approved",
|
||||
userId: "user-1",
|
||||
keyId: "board-key-1",
|
||||
expiresAt: "2026-03-23T13:00:00.000Z",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
action: "board_api_key.created",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs approve activity for instance admins without company memberships", async () => {
|
||||
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
|
||||
status: "approved",
|
||||
challenge: {
|
||||
id: "challenge-2",
|
||||
boardApiKeyId: "board-key-2",
|
||||
requestedAccess: "instance_admin_required",
|
||||
requestedCompanyId: null,
|
||||
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [],
|
||||
});
|
||||
const res = await request(app)
|
||||
.post("/api/cli-auth/challenges/challenge-2/approve")
|
||||
.send({ token: "pcp_cli_auth_secret" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).toHaveBeenCalledWith({
|
||||
userId: "admin-1",
|
||||
requestedCompanyId: null,
|
||||
boardApiKeyId: "board-key-2",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("logs revoke activity with resolved audit company ids", async () => {
|
||||
mockBoardAuthService.assertCurrentBoardKey.mockResolvedValue({
|
||||
id: "board-key-3",
|
||||
userId: "admin-2",
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "admin-2",
|
||||
keyId: "board-key-3",
|
||||
source: "board_key",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [],
|
||||
});
|
||||
const res = await request(app).post("/api/cli-auth/revoke-current").send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).toHaveBeenCalledWith({
|
||||
userId: "admin-2",
|
||||
boardApiKeyId: "board-key-3",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-z",
|
||||
action: "board_api_key.revoked",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { testEnvironment } from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
const itWindows = process.platform === "win32" ? it : it.skip;
|
||||
|
||||
describe("codex_local environment diagnostics", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
it("creates a missing working directory when cwd is absolute", async () => {
|
||||
const cwd = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-codex-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
"workspace",
|
||||
);
|
||||
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "codex_cwd_valid")).toBe(true);
|
||||
expect(result.checks.some((check) => check.level === "error")).toBe(false);
|
||||
const stats = await fs.stat(cwd);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("emits codex_native_auth_present when ~/.codex/auth.json exists and OPENAI_API_KEY is unset", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-codex-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const codexHome = path.join(root, ".codex");
|
||||
const cwd = path.join(root, "workspace");
|
||||
|
||||
try {
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(codexHome, "auth.json"),
|
||||
JSON.stringify({ accessToken: "fake-token", accountId: "acct-1" }),
|
||||
);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
env: { CODEX_HOME: codexHome },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "codex_native_auth_present")).toBe(true);
|
||||
expect(result.checks.some((check) => check.code === "codex_openai_api_key_missing")).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("emits codex_openai_api_key_missing when neither env var nor native auth exists", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-codex-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const codexHome = path.join(root, ".codex");
|
||||
const cwd = path.join(root, "workspace");
|
||||
|
||||
try {
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
// No auth.json written
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
env: { CODEX_HOME: codexHome },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "codex_openai_api_key_missing")).toBe(true);
|
||||
expect(result.checks.some((check) => check.code === "codex_native_auth_present")).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
itWindows("runs the hello probe when Codex is available via a Windows .cmd wrapper", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-codex-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const binDir = path.join(root, "bin");
|
||||
const cwd = path.join(root, "workspace");
|
||||
const fakeCodex = path.join(binDir, "codex.cmd");
|
||||
const script = [
|
||||
"@echo off",
|
||||
"echo {\"type\":\"thread.started\",\"thread_id\":\"test-thread\"}",
|
||||
"echo {\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"hello\"}}",
|
||||
"echo {\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":1,\"cached_input_tokens\":0,\"output_tokens\":1}}",
|
||||
"exit /b 0",
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
try {
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.writeFile(fakeCodex, script, "utf8");
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
command: "codex",
|
||||
cwd,
|
||||
env: {
|
||||
OPENAI_API_KEY: "test-key",
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
expect(result.checks.some((check) => check.code === "codex_hello_probe_passed")).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isCodexUnknownSessionError, parseCodexJsonl } from "@paperclipai/adapter-codex-local/server";
|
||||
import { parseCodexStdoutLine } from "@paperclipai/adapter-codex-local/ui";
|
||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||
|
||||
describe("codex_local parser", () => {
|
||||
it("extracts session, summary, usage, and terminal error message", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ type: "thread.started", thread_id: "thread-123" }),
|
||||
JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "hello" } }),
|
||||
JSON.stringify({ type: "turn.completed", usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 } }),
|
||||
JSON.stringify({ type: "turn.failed", error: { message: "model access denied" } }),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseCodexJsonl(stdout);
|
||||
expect(parsed.sessionId).toBe("thread-123");
|
||||
expect(parsed.summary).toBe("hello");
|
||||
expect(parsed.usage).toEqual({
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 2,
|
||||
outputTokens: 4,
|
||||
});
|
||||
expect(parsed.errorMessage).toBe("model access denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex_local stale session detection", () => {
|
||||
it("treats missing rollout path as an unknown session error", () => {
|
||||
const stderr =
|
||||
"2026-02-19T19:58:53.281939Z ERROR codex_core::rollout::list: state db missing rollout path for thread 019c775d-967c-7ef1-acc7-e396dc2c87cc";
|
||||
|
||||
expect(isCodexUnknownSessionError("", stderr)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex_local ui stdout parser", () => {
|
||||
it("parses turn and reasoning lifecycle events", () => {
|
||||
const ts = "2026-02-20T00:00:00.000Z";
|
||||
|
||||
expect(parseCodexStdoutLine(JSON.stringify({ type: "turn.started" }), ts)).toEqual([
|
||||
{ kind: "system", ts, text: "turn started" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: { id: "item_1", type: "reasoning", text: "**Preparing to use paperclip skill**" },
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "thinking", ts, text: "**Preparing to use paperclip skill**" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses command execution and file changes", () => {
|
||||
const ts = "2026-02-20T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "item.started",
|
||||
item: { id: "item_2", type: "command_execution", command: "/bin/zsh -lc ls", status: "in_progress" },
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "command_execution",
|
||||
toolUseId: "item_2",
|
||||
input: { id: "item_2", command: "/bin/zsh -lc ls" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "item_2",
|
||||
type: "command_execution",
|
||||
command: "/bin/zsh -lc ls",
|
||||
aggregated_output: "agents\n",
|
||||
exit_code: 0,
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: "item_2",
|
||||
content: "command: /bin/zsh -lc ls\nstatus: completed\nexit_code: 0\n\nagents",
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "item_52",
|
||||
type: "file_change",
|
||||
changes: [{ path: "/Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: "file changes: update /Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses error items and failed turns", () => {
|
||||
const ts = "2026-02-20T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "item_0",
|
||||
type: "error",
|
||||
message: "This session was recorded with model `gpt-5.2-pro` but is resuming with `gpt-5.2-codex`.",
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "stderr",
|
||||
ts,
|
||||
text: "This session was recorded with model `gpt-5.2-pro` but is resuming with `gpt-5.2-codex`.",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "turn.failed",
|
||||
error: { message: "model access denied" },
|
||||
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 },
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "",
|
||||
inputTokens: 10,
|
||||
outputTokens: 4,
|
||||
cachedTokens: 2,
|
||||
costUsd: 0,
|
||||
subtype: "turn.failed",
|
||||
isError: true,
|
||||
errors: ["model access denied"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
describe("codex_local cli formatter", () => {
|
||||
it("prints lifecycle, command execution, file change, and error events", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
printCodexStreamEvent(JSON.stringify({ type: "turn.started" }), false);
|
||||
printCodexStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "item.started",
|
||||
item: { id: "item_2", type: "command_execution", command: "/bin/zsh -lc ls", status: "in_progress" },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCodexStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "item_2",
|
||||
type: "command_execution",
|
||||
command: "/bin/zsh -lc ls",
|
||||
aggregated_output: "agents\n",
|
||||
exit_code: 0,
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCodexStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "item_52",
|
||||
type: "file_change",
|
||||
changes: [{ path: "/home/user/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCodexStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "turn.failed",
|
||||
error: { message: "model access denied" },
|
||||
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCodexStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: { type: "error", message: "resume model mismatch" },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
const lines = spy.mock.calls
|
||||
.map((call) => call.map((v) => String(v)).join(" "))
|
||||
.map(stripAnsi);
|
||||
|
||||
expect(lines).toEqual(expect.arrayContaining([
|
||||
"turn started",
|
||||
"tool_call: command_execution",
|
||||
"/bin/zsh -lc ls",
|
||||
"tool_result: command_execution command=\"/bin/zsh -lc ls\" status=completed exit_code=0",
|
||||
"agents",
|
||||
"file_change: update /home/user/project/ui/src/pages/AgentDetail.tsx",
|
||||
"turn failed: model access denied",
|
||||
"tokens: in=10 out=4 cached=2",
|
||||
"error: resume model mismatch",
|
||||
]));
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execute } from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
async function writeFakeCodexCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
codexHome: process.env.CODEX_HOME || null,
|
||||
paperclipEnvKeys: Object.keys(process.env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort(),
|
||||
};
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({ type: "thread.started", thread_id: "codex-session-1" }));
|
||||
console.log(JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "hello" } }));
|
||||
console.log(JSON.stringify({ type: "turn.completed", usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } }));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
type CapturePayload = {
|
||||
argv: string[];
|
||||
prompt: string;
|
||||
codexHome: string | null;
|
||||
paperclipEnvKeys: string[];
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
stream: "stdout" | "stderr";
|
||||
chunk: string;
|
||||
};
|
||||
|
||||
describe("codex execute", () => {
|
||||
it("uses a Paperclip-managed CODEX_HOME outside worktree mode while preserving shared auth and config", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-default-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
const sharedCodexHome = path.join(root, "shared-codex-home");
|
||||
const paperclipHome = path.join(root, "paperclip-home");
|
||||
const managedCodexHome = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"default",
|
||||
"companies",
|
||||
"company-1",
|
||||
"codex-home",
|
||||
);
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(sharedCodexHome, { recursive: true });
|
||||
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
|
||||
await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "codex-mini-latest"\n', "utf8");
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE;
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
process.env.HOME = root;
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_IN_WORKTREE;
|
||||
process.env.CODEX_HOME = sharedCodexHome;
|
||||
|
||||
try {
|
||||
const logs: LogEntry[] = [];
|
||||
const result = await execute({
|
||||
runId: "run-default",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async (stream, chunk) => {
|
||||
logs.push({ stream, chunk });
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.codexHome).toBe(managedCodexHome);
|
||||
|
||||
const managedAuth = path.join(managedCodexHome, "auth.json");
|
||||
const managedConfig = path.join(managedCodexHome, "config.toml");
|
||||
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
|
||||
expect((await fs.lstat(managedConfig)).isFile()).toBe(true);
|
||||
expect(await fs.readFile(managedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
|
||||
await expect(fs.lstat(path.join(sharedCodexHome, "companies", "company-1"))).rejects.toThrow();
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
stream: "stdout",
|
||||
chunk: expect.stringContaining("Using Paperclip-managed Codex home"),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
||||
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
|
||||
if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
|
||||
else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree;
|
||||
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = previousCodexHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("emits a command note that Codex auto-applies repo-scoped AGENTS.md files", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-notes-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
let commandNotes: string[] = [];
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-notes",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
commandNotes = Array.isArray(meta.commandNotes) ? meta.commandNotes : [];
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(commandNotes).toContain(
|
||||
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.",
|
||||
);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
const sharedCodexHome = path.join(root, "shared-codex-home");
|
||||
const paperclipHome = path.join(root, "paperclip-home");
|
||||
const isolatedCodexHome = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"worktree-1",
|
||||
"companies",
|
||||
"company-1",
|
||||
"codex-home",
|
||||
);
|
||||
const workspaceSkill = path.join(workspace, ".agents", "skills", "paperclip");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(sharedCodexHome, { recursive: true });
|
||||
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
|
||||
await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "codex-mini-latest"\n', "utf8");
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE;
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
process.env.HOME = root;
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "worktree-1";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.CODEX_HOME = sharedCodexHome;
|
||||
|
||||
try {
|
||||
const logs: LogEntry[] = [];
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async (stream, chunk) => {
|
||||
logs.push({ stream, chunk });
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.codexHome).toBe(isolatedCodexHome);
|
||||
expect(capture.argv).toEqual(expect.arrayContaining(["exec", "--json", "-"]));
|
||||
expect(capture.prompt).toContain("Follow the paperclip heartbeat.");
|
||||
expect(capture.paperclipEnvKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"PAPERCLIP_AGENT_ID",
|
||||
"PAPERCLIP_API_KEY",
|
||||
"PAPERCLIP_API_URL",
|
||||
"PAPERCLIP_COMPANY_ID",
|
||||
"PAPERCLIP_RUN_ID",
|
||||
]),
|
||||
);
|
||||
|
||||
const isolatedAuth = path.join(isolatedCodexHome, "auth.json");
|
||||
const isolatedConfig = path.join(isolatedCodexHome, "config.toml");
|
||||
|
||||
expect((await fs.lstat(isolatedAuth)).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
|
||||
expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true);
|
||||
expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
|
||||
expect((await fs.lstat(workspaceSkill)).isSymbolicLink()).toBe(true);
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
stream: "stdout",
|
||||
chunk: expect.stringContaining("Using worktree-isolated Codex home"),
|
||||
}),
|
||||
);
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
stream: "stdout",
|
||||
chunk: expect.stringContaining('Injected Codex skill "paperclip"'),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
||||
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
|
||||
if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
|
||||
else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree;
|
||||
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = previousCodexHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects an explicit CODEX_HOME config override even in worktree mode", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-explicit-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
const sharedCodexHome = path.join(root, "shared-codex-home");
|
||||
const explicitCodexHome = path.join(root, "explicit-codex-home");
|
||||
const paperclipHome = path.join(root, "paperclip-home");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(sharedCodexHome, { recursive: true });
|
||||
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE;
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
process.env.HOME = root;
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "worktree-1";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.CODEX_HOME = sharedCodexHome;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-2",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
CODEX_HOME: explicitCodexHome,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.codexHome).toBe(explicitCodexHome);
|
||||
expect((await fs.lstat(path.join(workspace, ".agents", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow();
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
||||
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
|
||||
if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
|
||||
else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree;
|
||||
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = previousCodexHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ensureCodexSkillsInjected } from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function createPaperclipRepoSkill(root: string, skillName: string) {
|
||||
await fs.mkdir(path.join(root, "server"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "packages", "adapter-utils"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "skills", skillName), { recursive: true });
|
||||
await fs.writeFile(path.join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n", "utf8");
|
||||
await fs.writeFile(path.join(root, "package.json"), '{"name":"paperclip"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(root, "skills", skillName, "SKILL.md"),
|
||||
`---\nname: ${skillName}\n---\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function createCustomSkill(root: string, skillName: string) {
|
||||
await fs.mkdir(path.join(root, "custom", skillName), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, "custom", skillName, "SKILL.md"),
|
||||
`---\nname: ${skillName}\n---\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe("codex local adapter skill injection", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("repairs a Codex Paperclip skill symlink that still points at another live checkout", async () => {
|
||||
const currentRepo = await makeTempDir("paperclip-codex-current-");
|
||||
const oldRepo = await makeTempDir("paperclip-codex-old-");
|
||||
const skillsHome = await makeTempDir("paperclip-codex-home-");
|
||||
cleanupDirs.add(currentRepo);
|
||||
cleanupDirs.add(oldRepo);
|
||||
cleanupDirs.add(skillsHome);
|
||||
|
||||
await createPaperclipRepoSkill(currentRepo, "paperclip");
|
||||
await createPaperclipRepoSkill(oldRepo, "paperclip");
|
||||
await fs.symlink(path.join(oldRepo, "skills", "paperclip"), path.join(skillsHome, "paperclip"));
|
||||
|
||||
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
|
||||
await ensureCodexSkillsInjected(
|
||||
async (stream, chunk) => {
|
||||
logs.push({ stream, chunk });
|
||||
},
|
||||
{
|
||||
skillsHome,
|
||||
skillsEntries: [{
|
||||
key: paperclipKey,
|
||||
runtimeName: "paperclip",
|
||||
source: path.join(currentRepo, "skills", "paperclip"),
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
|
||||
await fs.realpath(path.join(currentRepo, "skills", "paperclip")),
|
||||
);
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
stream: "stdout",
|
||||
chunk: expect.stringContaining('Repaired Codex skill "paperclip"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a custom Codex skill symlink outside Paperclip repo checkouts", async () => {
|
||||
const currentRepo = await makeTempDir("paperclip-codex-current-");
|
||||
const customRoot = await makeTempDir("paperclip-codex-custom-");
|
||||
const skillsHome = await makeTempDir("paperclip-codex-home-");
|
||||
cleanupDirs.add(currentRepo);
|
||||
cleanupDirs.add(customRoot);
|
||||
cleanupDirs.add(skillsHome);
|
||||
|
||||
await createPaperclipRepoSkill(currentRepo, "paperclip");
|
||||
await createCustomSkill(customRoot, "paperclip");
|
||||
await fs.symlink(path.join(customRoot, "custom", "paperclip"), path.join(skillsHome, "paperclip"));
|
||||
|
||||
await ensureCodexSkillsInjected(async () => {}, {
|
||||
skillsHome,
|
||||
skillsEntries: [{
|
||||
key: paperclipKey,
|
||||
runtimeName: "paperclip",
|
||||
source: path.join(currentRepo, "skills", "paperclip"),
|
||||
}],
|
||||
});
|
||||
|
||||
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
|
||||
await fs.realpath(path.join(customRoot, "custom", "paperclip")),
|
||||
);
|
||||
});
|
||||
|
||||
it("prunes broken symlinks for unavailable Paperclip repo skills before Codex starts", async () => {
|
||||
const currentRepo = await makeTempDir("paperclip-codex-current-");
|
||||
const oldRepo = await makeTempDir("paperclip-codex-old-");
|
||||
const skillsHome = await makeTempDir("paperclip-codex-home-");
|
||||
cleanupDirs.add(currentRepo);
|
||||
cleanupDirs.add(oldRepo);
|
||||
cleanupDirs.add(skillsHome);
|
||||
|
||||
await createPaperclipRepoSkill(currentRepo, "paperclip");
|
||||
await createPaperclipRepoSkill(oldRepo, "agent-browser");
|
||||
const staleTarget = path.join(oldRepo, "skills", "agent-browser");
|
||||
await fs.symlink(staleTarget, path.join(skillsHome, "agent-browser"));
|
||||
await fs.rm(staleTarget, { recursive: true, force: true });
|
||||
|
||||
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
|
||||
await ensureCodexSkillsInjected(
|
||||
async (stream, chunk) => {
|
||||
logs.push({ stream, chunk });
|
||||
},
|
||||
{
|
||||
skillsHome,
|
||||
skillsEntries: [{
|
||||
key: paperclipKey,
|
||||
runtimeName: "paperclip",
|
||||
source: path.join(currentRepo, "skills", "paperclip"),
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
await expect(fs.lstat(path.join(skillsHome, "agent-browser"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
stream: "stdout",
|
||||
chunk: expect.stringContaining('Removed stale Codex skill "agent-browser"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves other live Paperclip skill symlinks in the shared workspace skill directory", async () => {
|
||||
const currentRepo = await makeTempDir("paperclip-codex-current-");
|
||||
const skillsHome = await makeTempDir("paperclip-codex-home-");
|
||||
cleanupDirs.add(currentRepo);
|
||||
cleanupDirs.add(skillsHome);
|
||||
|
||||
await createPaperclipRepoSkill(currentRepo, "paperclip");
|
||||
await createPaperclipRepoSkill(currentRepo, "agent-browser");
|
||||
await fs.symlink(
|
||||
path.join(currentRepo, "skills", "agent-browser"),
|
||||
path.join(skillsHome, "agent-browser"),
|
||||
);
|
||||
|
||||
await ensureCodexSkillsInjected(async () => {}, {
|
||||
skillsHome,
|
||||
skillsEntries: [{
|
||||
key: paperclipKey,
|
||||
runtimeName: "paperclip",
|
||||
source: path.join(currentRepo, "skills", "paperclip"),
|
||||
}],
|
||||
});
|
||||
|
||||
expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
expect((await fs.lstat(path.join(skillsHome, "agent-browser"))).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(path.join(skillsHome, "agent-browser"))).toBe(
|
||||
await fs.realpath(path.join(currentRepo, "skills", "agent-browser")),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listCodexSkills,
|
||||
syncCodexSkills,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("codex local skill sync", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills for workspace injection on the next run", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-skill-sync-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listCodexSkills(ctx);
|
||||
expect(before.mode).toBe("ephemeral");
|
||||
expect(before.desiredSkills).toContain(paperclipKey);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain(".agents/skills");
|
||||
});
|
||||
|
||||
it("does not persist Paperclip skills into CODEX_HOME during sync", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-skill-prune-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncCodexSkills(configuredCtx, [paperclipKey]);
|
||||
expect(after.mode).toBe("ephemeral");
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
await expect(fs.lstat(path.join(codexHome, "skills", "paperclip"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps required bundled Paperclip skills configured even when the desired set is emptied", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-skill-required-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncCodexSkills(configuredCtx, []);
|
||||
expect(after.desiredSkills).toContain(paperclipKey);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
});
|
||||
|
||||
it("normalizes legacy flat Paperclip skill refs before reporting configured state", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-legacy-skill-sync-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
const snapshot = await listCodexSkills({
|
||||
agentId: "agent-3",
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.warnings).toEqual([]);
|
||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||
expect(snapshot.desiredSkills).not.toContain("paperclip");
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { companyRoutes } from "../routes/companies.js";
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
list: vi.fn(),
|
||||
stats: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}),
|
||||
companyPortabilityService: () => ({
|
||||
exportBundle: vi.fn(),
|
||||
previewExport: vi.fn(),
|
||||
previewImport: vi.fn(),
|
||||
importBundle: vi.fn(),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
}),
|
||||
budgetService: () => ({
|
||||
upsertPolicy: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
logActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("company routes malformed issue path guard", () => {
|
||||
it("returns a clear error when companyId is missing for issues list path", async () => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api/companies", companyRoutes({} as any));
|
||||
|
||||
const res = await request(app).get("/api/companies/issues");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { companyRoutes } from "../routes/companies.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
stats: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
ensureMembership: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBudgetService = vi.hoisted(() => ({
|
||||
upsertPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCompanyPortabilityService = vi.hoisted(() => ({
|
||||
exportBundle: vi.fn(),
|
||||
previewExport: vi.fn(),
|
||||
previewImport: vi.fn(),
|
||||
importBundle: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
budgetService: () => mockBudgetService,
|
||||
companyPortabilityService: () => mockCompanyPortabilityService,
|
||||
companyService: () => mockCompanyService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createCompany() {
|
||||
const now = new Date("2026-03-19T02:00:00.000Z");
|
||||
return {
|
||||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
description: null,
|
||||
status: "active",
|
||||
issuePrefix: "PAP",
|
||||
issueCounter: 568,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
brandColor: "#123456",
|
||||
logoAssetId: "11111111-1111-4111-8111-111111111111",
|
||||
logoUrl: "/api/assets/11111111-1111-4111-8111-111111111111/content",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api/companies", companyRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("PATCH /api/companies/:companyId/branding", () => {
|
||||
beforeEach(() => {
|
||||
mockCompanyService.update.mockReset();
|
||||
mockAgentService.getById.mockReset();
|
||||
mockLogActivity.mockReset();
|
||||
});
|
||||
|
||||
it("rejects non-CEO agent callers", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "engineer",
|
||||
});
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/companies/company-1/branding")
|
||||
.send({ logoAssetId: "11111111-1111-4111-8111-111111111111" });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Only CEO agents");
|
||||
expect(mockCompanyService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows CEO agent callers to update branding fields", async () => {
|
||||
const company = createCompany();
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "ceo",
|
||||
});
|
||||
mockCompanyService.update.mockResolvedValue(company);
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/companies/company-1/branding")
|
||||
.send({
|
||||
logoAssetId: "11111111-1111-4111-8111-111111111111",
|
||||
brandColor: "#123456",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.logoAssetId).toBe(company.logoAssetId);
|
||||
expect(mockCompanyService.update).toHaveBeenCalledWith("company-1", {
|
||||
logoAssetId: "11111111-1111-4111-8111-111111111111",
|
||||
brandColor: "#123456",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
action: "company.branding_updated",
|
||||
details: {
|
||||
logoAssetId: "11111111-1111-4111-8111-111111111111",
|
||||
brandColor: "#123456",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows board callers to update branding fields", async () => {
|
||||
const company = createCompany();
|
||||
mockCompanyService.update.mockResolvedValue({
|
||||
...company,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
});
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/companies/company-1/branding")
|
||||
.send({ brandColor: null, logoAssetId: null });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.brandColor).toBeNull();
|
||||
expect(res.body.logoAssetId).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-branding fields in the request body", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/companies/company-1/branding")
|
||||
.send({
|
||||
logoAssetId: "11111111-1111-4111-8111-111111111111",
|
||||
status: "archived",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("Validation error");
|
||||
expect(mockCompanyService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
stats: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
ensureMembership: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBudgetService = vi.hoisted(() => ({
|
||||
upsertPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCompanyPortabilityService = vi.hoisted(() => ({
|
||||
exportBundle: vi.fn(),
|
||||
previewExport: vi.fn(),
|
||||
previewImport: vi.fn(),
|
||||
importBundle: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
budgetService: () => mockBudgetService,
|
||||
companyPortabilityService: () => mockCompanyPortabilityService,
|
||||
companyService: () => mockCompanyService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const { companyRoutes } = await import("../routes/companies.js");
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api/companies", companyRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("company portability routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockAgentService.getById.mockReset();
|
||||
mockCompanyPortabilityService.exportBundle.mockReset();
|
||||
mockCompanyPortabilityService.previewExport.mockReset();
|
||||
mockCompanyPortabilityService.previewImport.mockReset();
|
||||
mockCompanyPortabilityService.importBundle.mockReset();
|
||||
mockLogActivity.mockReset();
|
||||
});
|
||||
|
||||
it("rejects non-CEO agents from CEO-safe export preview routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
role: "engineer",
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview")
|
||||
.send({ include: { company: true, agents: true, projects: true } });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Only CEO agents");
|
||||
expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows CEO agents to use company-scoped export preview routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
role: "ceo",
|
||||
});
|
||||
mockCompanyPortabilityService.previewExport.mockResolvedValue({
|
||||
rootPath: "paperclip",
|
||||
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false, skills: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null },
|
||||
files: {},
|
||||
fileInventory: [],
|
||||
counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 },
|
||||
warnings: [],
|
||||
paperclipExtensionPath: ".paperclip.yaml",
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview")
|
||||
.send({ include: { company: true, agents: true, projects: true } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockCompanyPortabilityService.previewExport).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
|
||||
include: { company: true, agents: true, projects: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects replace collision strategy on CEO-safe import routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
role: "ceo",
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/11111111-1111-4111-8111-111111111111/imports/preview")
|
||||
.send({
|
||||
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
|
||||
include: { company: true, agents: true, projects: false, issues: false },
|
||||
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
|
||||
collisionStrategy: "replace",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("does not allow replace");
|
||||
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps global import preview routes board-only", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/import/preview")
|
||||
.send({
|
||||
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
|
||||
include: { company: true, agents: true, projects: false, issues: false },
|
||||
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Board access required");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,113 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { companySkillRoutes } from "../routes/company-skills.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCompanySkillService = vi.hoisted(() => ({
|
||||
importFromSource: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", companySkillRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("company skill mutation permissions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
imported: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("allows local board operators to mutate company skills", async () => {
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks same-company agents without management permission from mutating company skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const res = await request(createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows agents with canCreateAgents to mutate company skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateAgents: true },
|
||||
});
|
||||
|
||||
const res = await request(createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
discoverProjectWorkspaceSkillDirectories,
|
||||
findMissingLocalSkillIds,
|
||||
normalizeGitHubSkillDirectory,
|
||||
parseSkillImportSourceInput,
|
||||
readLocalSkillImportFromDirectory,
|
||||
} from "../services/company-skills.js";
|
||||
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
async function makeTempDir(prefix: string) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
cleanupDirs.add(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function writeSkillDir(skillDir: string, name: string) {
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n\n# ${name}\n`, "utf8");
|
||||
}
|
||||
|
||||
describe("company skill import source parsing", () => {
|
||||
it("parses a skills.sh command without executing shell input", () => {
|
||||
const parsed = parseSkillImportSourceInput(
|
||||
"npx skills add https://github.com/vercel-labs/skills --skill find-skills",
|
||||
);
|
||||
|
||||
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
|
||||
expect(parsed.requestedSkillSlug).toBe("find-skills");
|
||||
expect(parsed.originalSkillsShUrl).toBeNull();
|
||||
expect(parsed.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses owner/repo/skill shorthand as skills.sh-managed", () => {
|
||||
const parsed = parseSkillImportSourceInput("vercel-labs/skills/find-skills");
|
||||
|
||||
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
|
||||
expect(parsed.requestedSkillSlug).toBe("find-skills");
|
||||
expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/vercel-labs/skills/find-skills");
|
||||
});
|
||||
|
||||
it("resolves skills.sh URL with org/repo/skill to GitHub repo and preserves original URL", () => {
|
||||
const parsed = parseSkillImportSourceInput(
|
||||
"https://skills.sh/google-labs-code/stitch-skills/design-md",
|
||||
);
|
||||
|
||||
expect(parsed.resolvedSource).toBe("https://github.com/google-labs-code/stitch-skills");
|
||||
expect(parsed.requestedSkillSlug).toBe("design-md");
|
||||
expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/google-labs-code/stitch-skills/design-md");
|
||||
});
|
||||
|
||||
it("resolves skills.sh URL with org/repo (no skill) to GitHub repo and preserves original URL", () => {
|
||||
const parsed = parseSkillImportSourceInput(
|
||||
"https://skills.sh/vercel-labs/skills",
|
||||
);
|
||||
|
||||
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
|
||||
expect(parsed.requestedSkillSlug).toBeNull();
|
||||
expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/vercel-labs/skills");
|
||||
});
|
||||
|
||||
it("parses skills.sh commands whose requested skill differs from the folder name", () => {
|
||||
const parsed = parseSkillImportSourceInput(
|
||||
"npx skills add https://github.com/remotion-dev/skills --skill remotion-best-practices",
|
||||
);
|
||||
|
||||
expect(parsed.resolvedSource).toBe("https://github.com/remotion-dev/skills");
|
||||
expect(parsed.requestedSkillSlug).toBe("remotion-best-practices");
|
||||
expect(parsed.originalSkillsShUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("does not set originalSkillsShUrl for owner/repo shorthand", () => {
|
||||
const parsed = parseSkillImportSourceInput("vercel-labs/skills");
|
||||
|
||||
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
|
||||
expect(parsed.originalSkillsShUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("project workspace skill discovery", () => {
|
||||
it("normalizes GitHub skill directories for blob imports and legacy metadata", () => {
|
||||
expect(normalizeGitHubSkillDirectory("retro/.", "retro")).toBe("retro");
|
||||
expect(normalizeGitHubSkillDirectory("retro/SKILL.md", "retro")).toBe("retro");
|
||||
expect(normalizeGitHubSkillDirectory("SKILL.md", "root-skill")).toBe("");
|
||||
expect(normalizeGitHubSkillDirectory("", "fallback-skill")).toBe("fallback-skill");
|
||||
});
|
||||
|
||||
it("finds bounded skill roots under supported workspace paths", async () => {
|
||||
const workspace = await makeTempDir("paperclip-skill-workspace-");
|
||||
await writeSkillDir(workspace, "Workspace Root");
|
||||
await writeSkillDir(path.join(workspace, "skills", "find-skills"), "Find Skills");
|
||||
await writeSkillDir(path.join(workspace, ".agents", "skills", "release"), "Release");
|
||||
await writeSkillDir(path.join(workspace, "skills", ".system", "paperclip"), "Paperclip");
|
||||
await fs.writeFile(path.join(workspace, "README.md"), "# ignore\n", "utf8");
|
||||
|
||||
const discovered = await discoverProjectWorkspaceSkillDirectories({
|
||||
projectId: "11111111-1111-1111-1111-111111111111",
|
||||
projectName: "Repo",
|
||||
workspaceId: "22222222-2222-2222-2222-222222222222",
|
||||
workspaceName: "Main",
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
|
||||
expect(discovered).toEqual([
|
||||
{ skillDir: path.resolve(workspace), inventoryMode: "project_root" },
|
||||
{ skillDir: path.resolve(workspace, ".agents", "skills", "release"), inventoryMode: "full" },
|
||||
{ skillDir: path.resolve(workspace, "skills", ".system", "paperclip"), inventoryMode: "full" },
|
||||
{ skillDir: path.resolve(workspace, "skills", "find-skills"), inventoryMode: "full" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("limits root SKILL.md imports to skill-related support folders", async () => {
|
||||
const workspace = await makeTempDir("paperclip-root-skill-");
|
||||
await writeSkillDir(workspace, "Workspace Skill");
|
||||
await fs.mkdir(path.join(workspace, "references"), { recursive: true });
|
||||
await fs.mkdir(path.join(workspace, "scripts"), { recursive: true });
|
||||
await fs.mkdir(path.join(workspace, "assets"), { recursive: true });
|
||||
await fs.mkdir(path.join(workspace, "src"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspace, "references", "checklist.md"), "# Checklist\n", "utf8");
|
||||
await fs.writeFile(path.join(workspace, "scripts", "run.sh"), "echo ok\n", "utf8");
|
||||
await fs.writeFile(path.join(workspace, "assets", "logo.svg"), "<svg />\n", "utf8");
|
||||
await fs.writeFile(path.join(workspace, "README.md"), "# Repo\n", "utf8");
|
||||
await fs.writeFile(path.join(workspace, "src", "index.ts"), "export {};\n", "utf8");
|
||||
|
||||
const imported = await readLocalSkillImportFromDirectory(
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
workspace,
|
||||
{ inventoryMode: "project_root", metadata: { sourceKind: "project_scan" } },
|
||||
);
|
||||
|
||||
expect(new Set(imported.fileInventory.map((entry) => entry.path))).toEqual(new Set([
|
||||
"assets/logo.svg",
|
||||
"references/checklist.md",
|
||||
"scripts/run.sh",
|
||||
"SKILL.md",
|
||||
]));
|
||||
expect(imported.fileInventory.map((entry) => entry.kind)).toContain("script");
|
||||
expect(imported.metadata?.sourceKind).toBe("project_scan");
|
||||
});
|
||||
|
||||
it("parses inline object array items in skill frontmatter metadata", async () => {
|
||||
const workspace = await makeTempDir("paperclip-inline-skill-yaml-");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, "SKILL.md"),
|
||||
[
|
||||
"---",
|
||||
"name: Inline Metadata Skill",
|
||||
"metadata:",
|
||||
" sources:",
|
||||
" - kind: github-dir",
|
||||
" repo: paperclipai/paperclip",
|
||||
" path: skills/paperclip",
|
||||
"---",
|
||||
"",
|
||||
"# Inline Metadata Skill",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const imported = await readLocalSkillImportFromDirectory(
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
workspace,
|
||||
{ inventoryMode: "full" },
|
||||
);
|
||||
|
||||
expect(imported.metadata).toMatchObject({
|
||||
sourceKind: "local_path",
|
||||
sources: [
|
||||
{
|
||||
kind: "github-dir",
|
||||
repo: "paperclipai/paperclip",
|
||||
path: "skills/paperclip",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing local skill reconciliation", () => {
|
||||
it("flags local-path skills whose directory was removed", async () => {
|
||||
const workspace = await makeTempDir("paperclip-missing-skill-dir-");
|
||||
const skillDir = path.join(workspace, "skills", "ghost");
|
||||
await writeSkillDir(skillDir, "Ghost");
|
||||
await fs.rm(skillDir, { recursive: true, force: true });
|
||||
|
||||
const missingIds = await findMissingLocalSkillIds([
|
||||
{
|
||||
id: "skill-1",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: skillDir,
|
||||
},
|
||||
{
|
||||
id: "skill-2",
|
||||
sourceType: "github",
|
||||
sourceLocator: "https://github.com/vercel-labs/agent-browser",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(missingIds).toEqual(["skill-1"]);
|
||||
});
|
||||
|
||||
it("flags local-path skills whose SKILL.md file was removed", async () => {
|
||||
const workspace = await makeTempDir("paperclip-missing-skill-file-");
|
||||
const skillDir = path.join(workspace, "skills", "ghost");
|
||||
await writeSkillDir(skillDir, "Ghost");
|
||||
await fs.rm(path.join(skillDir, "SKILL.md"), { force: true });
|
||||
|
||||
const missingIds = await findMissingLocalSkillIds([
|
||||
{
|
||||
id: "skill-1",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: skillDir,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(missingIds).toEqual(["skill-1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { costRoutes } from "../routes/costs.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
function makeDb(overrides: Record<string, unknown> = {}) {
|
||||
const selectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
leftJoin: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
groupBy: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const thenableChain = Object.assign(Promise.resolve([]), selectChain);
|
||||
|
||||
return {
|
||||
select: vi.fn().mockReturnValue(thenableChain),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
cancelBudgetScopeWork: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockFetchAllQuotaWindows = vi.hoisted(() => vi.fn());
|
||||
const mockCostService = vi.hoisted(() => ({
|
||||
createEvent: vi.fn(),
|
||||
summary: vi.fn().mockResolvedValue({ spendCents: 0 }),
|
||||
byAgent: vi.fn().mockResolvedValue([]),
|
||||
byAgentModel: vi.fn().mockResolvedValue([]),
|
||||
byProvider: vi.fn().mockResolvedValue([]),
|
||||
byBiller: vi.fn().mockResolvedValue([]),
|
||||
windowSpend: vi.fn().mockResolvedValue([]),
|
||||
byProject: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
const mockFinanceService = vi.hoisted(() => ({
|
||||
createEvent: vi.fn(),
|
||||
summary: vi.fn().mockResolvedValue({ debitCents: 0, creditCents: 0, netCents: 0, estimatedDebitCents: 0, eventCount: 0 }),
|
||||
byBiller: vi.fn().mockResolvedValue([]),
|
||||
byKind: vi.fn().mockResolvedValue([]),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
const mockBudgetService = vi.hoisted(() => ({
|
||||
overview: vi.fn().mockResolvedValue({
|
||||
companyId: "company-1",
|
||||
policies: [],
|
||||
activeIncidents: [],
|
||||
pausedAgentCount: 0,
|
||||
pausedProjectCount: 0,
|
||||
pendingApprovalCount: 0,
|
||||
}),
|
||||
upsertPolicy: vi.fn(),
|
||||
resolveIncident: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
budgetService: () => mockBudgetService,
|
||||
costService: () => mockCostService,
|
||||
financeService: () => mockFinanceService,
|
||||
companyService: () => mockCompanyService,
|
||||
agentService: () => mockAgentService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
vi.mock("../services/quota-windows.js", () => ({
|
||||
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", userId: "board-user", source: "local_implicit" };
|
||||
next();
|
||||
});
|
||||
app.use("/api", costRoutes(makeDb() as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function createAppWithActor(actor: any) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", costRoutes(makeDb() as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCompanyService.update.mockResolvedValue({
|
||||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
budgetMonthlyCents: 100,
|
||||
spentMonthlyCents: 0,
|
||||
});
|
||||
mockAgentService.update.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
budgetMonthlyCents: 100,
|
||||
spentMonthlyCents: 0,
|
||||
});
|
||||
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("cost routes", () => {
|
||||
it("accepts valid ISO date strings and passes them to cost summary routes", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/summary")
|
||||
.query({ from: "2026-01-01T00:00:00.000Z", to: "2026-01-31T23:59:59.999Z" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid 'from' date string", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/summary")
|
||||
.query({ from: "not-a-date" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid 'from' date/i);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid 'to' date string", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/summary")
|
||||
.query({ to: "banana" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid 'to' date/i);
|
||||
});
|
||||
|
||||
it("returns finance summary rows for valid requests", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-summary")
|
||||
.query({ from: "2026-02-01T00:00:00.000Z", to: "2026-02-28T23:59:59.999Z" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockFinanceService.summary).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid finance event list limits", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-events")
|
||||
.query({ limit: "0" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid 'limit'/i);
|
||||
});
|
||||
|
||||
it("accepts valid finance event list limits", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-events")
|
||||
.query({ limit: "25" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockFinanceService.list).toHaveBeenCalledWith("company-1", undefined, 25);
|
||||
});
|
||||
|
||||
it("rejects company budget updates for board users outside the company", async () => {
|
||||
const app = createAppWithActor({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-2"],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/companies/company-1/budgets")
|
||||
.send({ budgetMonthlyCents: 2500 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockCompanyService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent budget updates for board users outside the agent company", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
budgetMonthlyCents: 100,
|
||||
spentMonthlyCents: 0,
|
||||
});
|
||||
const app = createAppWithActor({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-2"],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/agents/agent-1/budgets")
|
||||
.send({ budgetMonthlyCents: 2500 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { testEnvironment } from "@paperclipai/adapter-cursor-local/server";
|
||||
|
||||
async function writeFakeAgentCommand(binDir: string, argsCapturePath: string): Promise<string> {
|
||||
const commandPath = path.join(binDir, "agent");
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const outPath = process.env.PAPERCLIP_TEST_ARGS_PATH;
|
||||
if (outPath) {
|
||||
fs.writeFileSync(outPath, JSON.stringify(process.argv.slice(2)), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "hello",
|
||||
}));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
return commandPath;
|
||||
}
|
||||
|
||||
describe("cursor environment diagnostics", () => {
|
||||
it("creates a missing working directory when cwd is absolute", async () => {
|
||||
const cwd = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-cursor-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
"workspace",
|
||||
);
|
||||
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "cursor_cwd_valid")).toBe(true);
|
||||
expect(result.checks.some((check) => check.level === "error")).toBe(false);
|
||||
const stats = await fs.stat(cwd);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("adds --yolo to hello probe args by default", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-cursor-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const binDir = path.join(root, "bin");
|
||||
const cwd = path.join(root, "workspace");
|
||||
const argsCapturePath = path.join(root, "args.json");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeFakeAgentCommand(binDir, argsCapturePath);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
command: "agent",
|
||||
cwd,
|
||||
env: {
|
||||
CURSOR_API_KEY: "test-key",
|
||||
PAPERCLIP_TEST_ARGS_PATH: argsCapturePath,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
const args = JSON.parse(await fs.readFile(argsCapturePath, "utf8")) as string[];
|
||||
expect(args).toContain("--yolo");
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("does not auto-add --yolo when extraArgs already bypass trust", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-cursor-local-probe-extra-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const binDir = path.join(root, "bin");
|
||||
const cwd = path.join(root, "workspace");
|
||||
const argsCapturePath = path.join(root, "args.json");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeFakeAgentCommand(binDir, argsCapturePath);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
command: "agent",
|
||||
cwd,
|
||||
extraArgs: ["--yolo"],
|
||||
env: {
|
||||
CURSOR_API_KEY: "test-key",
|
||||
PAPERCLIP_TEST_ARGS_PATH: argsCapturePath,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
const args = JSON.parse(await fs.readFile(argsCapturePath, "utf8")) as string[];
|
||||
expect(args).toContain("--yolo");
|
||||
expect(args).not.toContain("--trust");
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isCursorUnknownSessionError, parseCursorJsonl } from "@paperclipai/adapter-cursor-local/server";
|
||||
import { parseCursorStdoutLine } from "@paperclipai/adapter-cursor-local/ui";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
|
||||
describe("cursor parser", () => {
|
||||
it("extracts session, summary, usage, cost, and terminal error message", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "chat_123", model: "gpt-5" }),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "output_text", text: "hello" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "chat_123",
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 25,
|
||||
output_tokens: 40,
|
||||
},
|
||||
total_cost_usd: 0.001,
|
||||
result: "Task complete",
|
||||
}),
|
||||
JSON.stringify({ type: "error", message: "model access denied" }),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseCursorJsonl(stdout);
|
||||
expect(parsed.sessionId).toBe("chat_123");
|
||||
expect(parsed.summary).toBe("hello");
|
||||
expect(parsed.usage).toEqual({
|
||||
inputTokens: 100,
|
||||
cachedInputTokens: 25,
|
||||
outputTokens: 40,
|
||||
});
|
||||
expect(parsed.costUsd).toBeCloseTo(0.001, 6);
|
||||
expect(parsed.errorMessage).toBe("model access denied");
|
||||
});
|
||||
|
||||
it("parses multiplexed stdout-prefixed json lines", () => {
|
||||
const stdout = [
|
||||
'stdout{"type":"system","subtype":"init","session_id":"chat_prefixed","model":"gpt-5"}',
|
||||
'stdout{"type":"assistant","message":{"content":[{"type":"output_text","text":"prefixed hello"}]}}',
|
||||
'stdout{"type":"result","subtype":"success","usage":{"input_tokens":3,"output_tokens":2,"cached_input_tokens":1},"total_cost_usd":0.0001}',
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseCursorJsonl(stdout);
|
||||
expect(parsed.sessionId).toBe("chat_prefixed");
|
||||
expect(parsed.summary).toBe("prefixed hello");
|
||||
expect(parsed.usage).toEqual({
|
||||
inputTokens: 3,
|
||||
cachedInputTokens: 1,
|
||||
outputTokens: 2,
|
||||
});
|
||||
expect(parsed.costUsd).toBeCloseTo(0.0001, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor stale session detection", () => {
|
||||
it("treats missing/unknown session messages as an unknown session error", () => {
|
||||
expect(isCursorUnknownSessionError("", "unknown session id chat_123")).toBe(true);
|
||||
expect(isCursorUnknownSessionError("", "chat abc not found")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor ui stdout parser", () => {
|
||||
it("parses assistant, thinking, and tool lifecycle events", () => {
|
||||
const ts = "2026-03-05T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "output_text", text: "I will run a command." },
|
||||
{ type: "thinking", text: "Checking repository state" },
|
||||
{ type: "tool_call", name: "bash", input: { command: "ls -1" } },
|
||||
{ type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "assistant", ts, text: "I will run a command." },
|
||||
{ kind: "thinking", ts, text: "Checking repository state" },
|
||||
{ kind: "tool_call", ts, name: "bash", input: { command: "ls -1" } },
|
||||
{ kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses result usage and errors", () => {
|
||||
const ts = "2026-03-05T00:00:00.000Z";
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Done",
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cached_input_tokens: 2,
|
||||
},
|
||||
total_cost_usd: 0.00042,
|
||||
is_error: false,
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "Done",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cachedTokens: 2,
|
||||
costUsd: 0.00042,
|
||||
subtype: "success",
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses stdout-prefixed json lines", () => {
|
||||
const ts = "2026-03-05T00:00:00.000Z";
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
'stdout{"type":"assistant","message":{"content":[{"type":"thinking","text":"streamed"}]}}',
|
||||
ts,
|
||||
),
|
||||
).toEqual([{ kind: "thinking", ts, text: "streamed" }]);
|
||||
});
|
||||
|
||||
it("compacts shellToolCall and shell tool result for run log", () => {
|
||||
const ts = "2026-03-05T00:00:00.000Z";
|
||||
const longCommand = "curl -s -X POST \"$PAPERCLIP_API_URL/api/issues/abc/checkout\" -H \"Authorization: Bearer $PAPERCLIP_API_KEY\"";
|
||||
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "tool_call",
|
||||
subtype: "started",
|
||||
call_id: "call_shell_1",
|
||||
tool_call: {
|
||||
shellToolCall: {
|
||||
command: longCommand,
|
||||
workingDirectory: "/tmp",
|
||||
timeout: 30000,
|
||||
toolCallId: "tool_xyz",
|
||||
simpleCommands: ["curl"],
|
||||
parsingResult: { parsingFailed: false, executableCommands: [] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "shellToolCall",
|
||||
toolUseId: "call_shell_1",
|
||||
input: { command: longCommand },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "tool_call",
|
||||
subtype: "completed",
|
||||
call_id: "call_shell_1",
|
||||
tool_call: {
|
||||
shellToolCall: {
|
||||
result: {
|
||||
success: {
|
||||
command: longCommand,
|
||||
exitCode: 0,
|
||||
stdout: '{"id":"abc","status":"in_progress"}',
|
||||
stderr: "",
|
||||
executionTime: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: "call_shell_1",
|
||||
content: "exit 0\n<stdout>\n{\"id\":\"abc\",\"status\":\"in_progress\"}",
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses user, top-level thinking, and top-level tool_call events", () => {
|
||||
const ts = "2026-03-05T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Please inspect README.md" }],
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([{ kind: "user", ts, text: "Please inspect README.md" }]);
|
||||
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "thinking",
|
||||
subtype: "delta",
|
||||
text: "planning next command",
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([{ kind: "thinking", ts, text: "planning next command", delta: true }]);
|
||||
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "thinking",
|
||||
subtype: "delta",
|
||||
text: " with preserved leading space",
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([{ kind: "thinking", ts, text: " with preserved leading space", delta: true }]);
|
||||
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "tool_call",
|
||||
subtype: "started",
|
||||
call_id: "call_1",
|
||||
tool_call: {
|
||||
readToolCall: {
|
||||
args: { path: "README.md" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", toolUseId: "call_1", input: { path: "README.md" } }]);
|
||||
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "tool_call",
|
||||
subtype: "completed",
|
||||
call_id: "call_1",
|
||||
tool_call: {
|
||||
readToolCall: {
|
||||
result: { success: { content: "README contents" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: "call_1",
|
||||
content: '{\n "success": {\n "content": "README contents"\n }\n}',
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
describe("cursor cli formatter", () => {
|
||||
it("prints init, user, assistant, tool, and result events", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
printCursorStreamEvent(
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "chat_abc", model: "gpt-5" }),
|
||||
false,
|
||||
);
|
||||
printCursorStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{ type: "text", text: "run tests" }],
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCursorStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "output_text", text: "hello" }],
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCursorStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "thinking",
|
||||
subtype: "delta",
|
||||
text: "looking at package.json",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCursorStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "tool_call", name: "bash", input: { command: "ls -1" } }],
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCursorStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "tool_result", output: "AGENTS.md", status: "ok" }],
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCursorStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "tool_call",
|
||||
subtype: "started",
|
||||
call_id: "call_1",
|
||||
tool_call: {
|
||||
readToolCall: {
|
||||
args: { path: "README.md" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCursorStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "tool_call",
|
||||
subtype: "completed",
|
||||
call_id: "call_1",
|
||||
tool_call: {
|
||||
readToolCall: {
|
||||
result: { success: { content: "README contents" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCursorStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Done",
|
||||
usage: { input_tokens: 10, output_tokens: 5, cached_input_tokens: 2 },
|
||||
total_cost_usd: 0.00042,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
const lines = spy.mock.calls
|
||||
.map((call) => call.map((v) => String(v)).join(" "))
|
||||
.map(stripAnsi);
|
||||
|
||||
expect(lines).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Cursor init (session: chat_abc, model: gpt-5)",
|
||||
"user: run tests",
|
||||
"assistant: hello",
|
||||
"thinking: looking at package.json",
|
||||
"tool_call: bash",
|
||||
"tool_call: readToolCall (call_1)",
|
||||
"tool_result (call_1)",
|
||||
'{\n "success": {\n "content": "README contents"\n }\n}',
|
||||
"tool_result",
|
||||
"AGENTS.md",
|
||||
"result: subtype=success",
|
||||
"tokens: in=10 out=5 cached=2 cost=$0.000420",
|
||||
"assistant: Done",
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execute } from "@paperclipai/adapter-cursor-local/server";
|
||||
|
||||
async function writeFakeCursorCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
paperclipEnvKeys: Object.keys(process.env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort(),
|
||||
};
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "cursor-session-1",
|
||||
model: "auto",
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "cursor-session-1",
|
||||
result: "ok",
|
||||
}));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
type CapturePayload = {
|
||||
argv: string[];
|
||||
prompt: string;
|
||||
paperclipEnvKeys: string[];
|
||||
};
|
||||
|
||||
async function createSkillDir(root: string, name: string) {
|
||||
const skillDir = path.join(root, name);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
describe("cursor execute", () => {
|
||||
it("injects paperclip env vars and prompt note by default", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "agent");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCursorCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
let invocationPrompt = "";
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Cursor Coder",
|
||||
adapterType: "cursor",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model: "auto",
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
invocationPrompt = meta.prompt ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).not.toContain("Follow the paperclip heartbeat.");
|
||||
expect(capture.argv).not.toContain("--mode");
|
||||
expect(capture.argv).not.toContain("ask");
|
||||
expect(capture.paperclipEnvKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"PAPERCLIP_AGENT_ID",
|
||||
"PAPERCLIP_API_KEY",
|
||||
"PAPERCLIP_API_URL",
|
||||
"PAPERCLIP_COMPANY_ID",
|
||||
"PAPERCLIP_RUN_ID",
|
||||
]),
|
||||
);
|
||||
expect(capture.prompt).toContain("Paperclip runtime note:");
|
||||
expect(capture.prompt).toContain("PAPERCLIP_API_KEY");
|
||||
expect(invocationPrompt).toContain("Paperclip runtime note:");
|
||||
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("passes --mode when explicitly configured", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-mode-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "agent");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCursorCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-2",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Cursor Coder",
|
||||
adapterType: "cursor",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model: "auto",
|
||||
mode: "ask",
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toContain("--mode");
|
||||
expect(capture.argv).toContain("ask");
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("injects company-library runtime skills into the Cursor skills home before execution", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-runtime-skill-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "agent");
|
||||
const runtimeSkillsRoot = path.join(root, "runtime-skills");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCursorCommand(commandPath);
|
||||
|
||||
const paperclipDir = await createSkillDir(runtimeSkillsRoot, "paperclip");
|
||||
const asciiHeartDir = await createSkillDir(runtimeSkillsRoot, "ascii-heart");
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-3",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Cursor Coder",
|
||||
adapterType: "cursor",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model: "auto",
|
||||
paperclipRuntimeSkills: [
|
||||
{
|
||||
name: "paperclip",
|
||||
source: paperclipDir,
|
||||
required: true,
|
||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
||||
},
|
||||
{
|
||||
name: "ascii-heart",
|
||||
source: asciiHeartDir,
|
||||
},
|
||||
],
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["ascii-heart"],
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect((await fs.lstat(path.join(root, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(path.join(root, ".cursor", "skills", "ascii-heart"))).toBe(
|
||||
await fs.realpath(asciiHeartDir),
|
||||
);
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ensureCursorSkillsInjected } from "@paperclipai/adapter-cursor-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function createSkillDir(root: string, name: string) {
|
||||
await fs.mkdir(path.join(root, name), { recursive: true });
|
||||
}
|
||||
|
||||
describe("cursor local adapter skill injection", () => {
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("links missing Paperclip skills into Cursor skills home", async () => {
|
||||
const skillsDir = await makeTempDir("paperclip-cursor-skills-src-");
|
||||
const skillsHome = await makeTempDir("paperclip-cursor-skills-home-");
|
||||
cleanupDirs.add(skillsDir);
|
||||
cleanupDirs.add(skillsHome);
|
||||
|
||||
await createSkillDir(skillsDir, "paperclip");
|
||||
await createSkillDir(skillsDir, "paperclip-create-agent");
|
||||
await fs.writeFile(path.join(skillsDir, "README.txt"), "ignore", "utf8");
|
||||
|
||||
const logs: string[] = [];
|
||||
await ensureCursorSkillsInjected(
|
||||
async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
{ skillsDir, skillsHome },
|
||||
);
|
||||
|
||||
const injectedA = path.join(skillsHome, "paperclip");
|
||||
const injectedB = path.join(skillsHome, "paperclip-create-agent");
|
||||
expect((await fs.lstat(injectedA)).isSymbolicLink()).toBe(true);
|
||||
expect((await fs.lstat(injectedB)).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(injectedA)).toBe(await fs.realpath(path.join(skillsDir, "paperclip")));
|
||||
expect(await fs.realpath(injectedB)).toBe(
|
||||
await fs.realpath(path.join(skillsDir, "paperclip-create-agent")),
|
||||
);
|
||||
expect(logs.some((line) => line.includes('Injected Cursor skill "paperclip"'))).toBe(true);
|
||||
expect(logs.some((line) => line.includes('Injected Cursor skill "paperclip-create-agent"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves existing targets and only links missing skills", async () => {
|
||||
const skillsDir = await makeTempDir("paperclip-cursor-preserve-src-");
|
||||
const skillsHome = await makeTempDir("paperclip-cursor-preserve-home-");
|
||||
cleanupDirs.add(skillsDir);
|
||||
cleanupDirs.add(skillsHome);
|
||||
|
||||
await createSkillDir(skillsDir, "paperclip");
|
||||
await createSkillDir(skillsDir, "paperclip-create-agent");
|
||||
|
||||
const existingTarget = path.join(skillsHome, "paperclip");
|
||||
await fs.mkdir(existingTarget, { recursive: true });
|
||||
await fs.writeFile(path.join(existingTarget, "keep.txt"), "keep", "utf8");
|
||||
|
||||
await ensureCursorSkillsInjected(async () => {}, { skillsDir, skillsHome });
|
||||
|
||||
expect((await fs.lstat(existingTarget)).isDirectory()).toBe(true);
|
||||
expect(await fs.readFile(path.join(existingTarget, "keep.txt"), "utf8")).toBe("keep");
|
||||
expect((await fs.lstat(path.join(skillsHome, "paperclip-create-agent"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("logs per-skill link failures and continues without throwing", async () => {
|
||||
const skillsDir = await makeTempDir("paperclip-cursor-fail-src-");
|
||||
const skillsHome = await makeTempDir("paperclip-cursor-fail-home-");
|
||||
cleanupDirs.add(skillsDir);
|
||||
cleanupDirs.add(skillsHome);
|
||||
|
||||
await createSkillDir(skillsDir, "ok-skill");
|
||||
await createSkillDir(skillsDir, "fail-skill");
|
||||
|
||||
const logs: string[] = [];
|
||||
await ensureCursorSkillsInjected(
|
||||
async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
{
|
||||
skillsDir,
|
||||
skillsHome,
|
||||
linkSkill: async (source, target) => {
|
||||
if (target.endsWith(`${path.sep}fail-skill`)) {
|
||||
throw new Error("simulated link failure");
|
||||
}
|
||||
await fs.symlink(source, target);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect((await fs.lstat(path.join(skillsHome, "ok-skill"))).isSymbolicLink()).toBe(true);
|
||||
await expect(fs.lstat(path.join(skillsHome, "fail-skill"))).rejects.toThrow();
|
||||
expect(logs.some((line) => line.includes('Failed to inject Cursor skill "fail-skill"'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listCursorSkills,
|
||||
syncCursorSkills,
|
||||
} from "@paperclipai/adapter-cursor-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function createSkillDir(root: string, name: string) {
|
||||
const skillDir = path.join(root, name);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
describe("cursor local skill sync", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the Cursor skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-cursor-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listCursorSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toContain(paperclipKey);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||
|
||||
const after = await syncCursorSkills(ctx, [paperclipKey]);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes company-library runtime skills supplied outside the bundled Paperclip directory", async () => {
|
||||
const home = await makeTempDir("paperclip-cursor-runtime-skills-home-");
|
||||
const runtimeSkills = await makeTempDir("paperclip-cursor-runtime-skills-src-");
|
||||
cleanupDirs.add(home);
|
||||
cleanupDirs.add(runtimeSkills);
|
||||
|
||||
const paperclipDir = await createSkillDir(runtimeSkills, "paperclip");
|
||||
const asciiHeartDir = await createSkillDir(runtimeSkills, "ascii-heart");
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-3",
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipRuntimeSkills: [
|
||||
{
|
||||
key: "paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source: paperclipDir,
|
||||
required: true,
|
||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
||||
},
|
||||
{
|
||||
key: "ascii-heart",
|
||||
runtimeName: "ascii-heart",
|
||||
source: asciiHeartDir,
|
||||
},
|
||||
],
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["ascii-heart"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listCursorSkills(ctx);
|
||||
expect(before.warnings).toEqual([]);
|
||||
expect(before.desiredSkills).toEqual(["paperclip", "ascii-heart"]);
|
||||
expect(before.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("missing");
|
||||
|
||||
const after = await syncCursorSkills(ctx, ["ascii-heart"]);
|
||||
expect(after.warnings).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-cursor-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncCursorSkills(configuredCtx, [paperclipKey]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncCursorSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toContain(paperclipKey);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldTrackDevServerPath } from "../../../scripts/dev-runner-paths.mjs";
|
||||
|
||||
describe("shouldTrackDevServerPath", () => {
|
||||
it("ignores repo-local Paperclip state and common test file paths", () => {
|
||||
expect(
|
||||
shouldTrackDevServerPath(
|
||||
".paperclip/worktrees/PAP-712-for-project-configuration-get-rid-of-the-overview-tab-for-now/.agents/skills/paperclip",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(shouldTrackDevServerPath("server/src/__tests__/health.test.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.test.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.spec.tsx")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/_tests/helpers.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/tests/helpers.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/test/helpers.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("vitest.config.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps runtime paths restart-relevant", () => {
|
||||
expect(shouldTrackDevServerPath("server/src/routes/health.ts")).toBe(true);
|
||||
expect(shouldTrackDevServerPath("packages/shared/src/index.ts")).toBe(true);
|
||||
expect(shouldTrackDevServerPath("server/src/testing/runtime.ts")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
|
||||
|
||||
const tempDirs = [];
|
||||
|
||||
function createTempStatusFile(payload: unknown) {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-status-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "dev-server-status.json");
|
||||
writeFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("dev server status helpers", () => {
|
||||
it("reads and normalizes persisted supervisor state", () => {
|
||||
const filePath = createTempStatusFile({
|
||||
dirty: true,
|
||||
lastChangedAt: "2026-03-20T12:00:00.000Z",
|
||||
changedPathCount: 4,
|
||||
changedPathsSample: ["server/src/app.ts", "packages/shared/src/index.ts"],
|
||||
pendingMigrations: ["0040_restart_banner.sql"],
|
||||
lastRestartAt: "2026-03-20T11:30:00.000Z",
|
||||
});
|
||||
|
||||
expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toEqual({
|
||||
dirty: true,
|
||||
lastChangedAt: "2026-03-20T12:00:00.000Z",
|
||||
changedPathCount: 4,
|
||||
changedPathsSample: ["server/src/app.ts", "packages/shared/src/index.ts"],
|
||||
pendingMigrations: ["0040_restart_banner.sql"],
|
||||
lastRestartAt: "2026-03-20T11:30:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives waiting-for-idle health state", () => {
|
||||
const health = toDevServerHealthStatus(
|
||||
{
|
||||
dirty: true,
|
||||
lastChangedAt: "2026-03-20T12:00:00.000Z",
|
||||
changedPathCount: 2,
|
||||
changedPathsSample: ["server/src/app.ts"],
|
||||
pendingMigrations: [],
|
||||
lastRestartAt: "2026-03-20T11:30:00.000Z",
|
||||
},
|
||||
{ autoRestartEnabled: true, activeRunCount: 3 },
|
||||
);
|
||||
|
||||
expect(health).toMatchObject({
|
||||
enabled: true,
|
||||
restartRequired: true,
|
||||
reason: "backend_changes",
|
||||
autoRestartEnabled: true,
|
||||
activeRunCount: 3,
|
||||
waitingForIdle: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractLegacyPlanBody } from "../services/documents.js";
|
||||
|
||||
describe("extractLegacyPlanBody", () => {
|
||||
it("returns null when no plan block exists", () => {
|
||||
expect(extractLegacyPlanBody("hello world")).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts plan body from legacy issue descriptions", () => {
|
||||
expect(
|
||||
extractLegacyPlanBody(`
|
||||
intro
|
||||
|
||||
<plan>
|
||||
|
||||
# Plan
|
||||
|
||||
- one
|
||||
- two
|
||||
|
||||
</plan>
|
||||
`),
|
||||
).toBe("# Plan\n\n- one\n- two");
|
||||
});
|
||||
|
||||
it("ignores empty plan blocks", () => {
|
||||
expect(extractLegacyPlanBody("<plan> </plan>")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
|
||||
function makeReq(): Request {
|
||||
return {
|
||||
method: "GET",
|
||||
originalUrl: "/api/test",
|
||||
body: { a: 1 },
|
||||
params: { id: "123" },
|
||||
query: { q: "x" },
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
function makeRes(): Response {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
} as unknown as Response;
|
||||
(res.status as unknown as ReturnType<typeof vi.fn>).mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("errorHandler", () => {
|
||||
it("attaches the original Error to res.err for 500s", () => {
|
||||
const req = makeReq();
|
||||
const res = makeRes() as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const err = new Error("boom");
|
||||
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
|
||||
expect(res.err).toBe(err);
|
||||
expect(res.__errorContext?.error?.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("attaches HttpError instances for 500 responses", () => {
|
||||
const req = makeReq();
|
||||
const res = makeRes() as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const err = new HttpError(500, "db exploded");
|
||||
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "db exploded" });
|
||||
expect(res.err).toBe(err);
|
||||
expect(res.__errorContext?.error?.message).toBe("db exploded");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceMode,
|
||||
} from "../services/execution-workspace-policy.ts";
|
||||
|
||||
describe("execution workspace policy helpers", () => {
|
||||
it("defaults new issue settings from enabled project policy", () => {
|
||||
expect(
|
||||
defaultIssueExecutionWorkspaceSettingsForProject({
|
||||
enabled: true,
|
||||
defaultMode: "isolated_workspace",
|
||||
}),
|
||||
).toEqual({ mode: "isolated_workspace" });
|
||||
expect(
|
||||
defaultIssueExecutionWorkspaceSettingsForProject({
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
}),
|
||||
).toEqual({ mode: "shared_workspace" });
|
||||
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers explicit issue mode over project policy and legacy overrides", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: { enabled: true, defaultMode: "shared_workspace" },
|
||||
issueSettings: { mode: "isolated_workspace" },
|
||||
legacyUseProjectWorkspace: false,
|
||||
}),
|
||||
).toBe("isolated_workspace");
|
||||
});
|
||||
|
||||
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
||||
issueSettings: null,
|
||||
legacyUseProjectWorkspace: false,
|
||||
}),
|
||||
).toBe("isolated_workspace");
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: null,
|
||||
issueSettings: null,
|
||||
legacyUseProjectWorkspace: false,
|
||||
}),
|
||||
).toBe("agent_default");
|
||||
});
|
||||
|
||||
it("applies project policy strategy and runtime defaults when isolation is enabled", () => {
|
||||
const result = buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: {
|
||||
workspaceStrategy: { type: "project_primary" },
|
||||
},
|
||||
projectPolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "isolated_workspace",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
baseRef: "origin/main",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
issueSettings: null,
|
||||
mode: "isolated_workspace",
|
||||
legacyUseProjectWorkspace: null,
|
||||
});
|
||||
|
||||
expect(result.workspaceStrategy).toEqual({
|
||||
type: "git_worktree",
|
||||
baseRef: "origin/main",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
});
|
||||
expect(result.workspaceRuntime).toEqual({
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears managed workspace strategy when issue opts out to project primary or agent default", () => {
|
||||
const baseConfig = {
|
||||
workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" },
|
||||
workspaceRuntime: { services: [{ name: "web" }] },
|
||||
};
|
||||
|
||||
expect(
|
||||
buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: baseConfig,
|
||||
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
||||
issueSettings: { mode: "shared_workspace" },
|
||||
mode: "shared_workspace",
|
||||
legacyUseProjectWorkspace: null,
|
||||
}).workspaceStrategy,
|
||||
).toBeUndefined();
|
||||
|
||||
const agentDefault = buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: baseConfig,
|
||||
projectPolicy: null,
|
||||
issueSettings: { mode: "agent_default" },
|
||||
mode: "agent_default",
|
||||
legacyUseProjectWorkspace: null,
|
||||
});
|
||||
expect(agentDefault.workspaceStrategy).toBeUndefined();
|
||||
expect(agentDefault.workspaceRuntime).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses persisted JSON payloads into typed project and issue workspace settings", () => {
|
||||
expect(
|
||||
parseProjectExecutionWorkspacePolicy({
|
||||
enabled: true,
|
||||
defaultMode: "isolated",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
worktreeParentDir: ".paperclip/worktrees",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
defaultMode: "isolated_workspace",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
worktreeParentDir: ".paperclip/worktrees",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseIssueExecutionWorkspaceSettings({
|
||||
mode: "project_primary",
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "shared_workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps persisted execution workspace modes back to issue settings", () => {
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("shared_workspace")).toBe("shared_workspace");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("adapter_managed")).toBe("agent_default");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("cloud_sandbox")).toBe("agent_default");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace(null)).toBe("agent_default");
|
||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace(undefined)).toBe("agent_default");
|
||||
});
|
||||
|
||||
it("disables project execution workspace policy when the instance flag is off", () => {
|
||||
expect(
|
||||
gateProjectExecutionWorkspacePolicy(
|
||||
{ enabled: true, defaultMode: "isolated_workspace" },
|
||||
false,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
gateProjectExecutionWorkspacePolicy(
|
||||
{ enabled: true, defaultMode: "isolated_workspace" },
|
||||
true,
|
||||
),
|
||||
).toEqual({ enabled: true, defaultMode: "isolated_workspace" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
resolveDynamicForbiddenTokens,
|
||||
resolveForbiddenTokens,
|
||||
runForbiddenTokenCheck,
|
||||
} = await import("../../../scripts/check-forbidden-tokens.mjs");
|
||||
|
||||
describe("forbidden token check", () => {
|
||||
it("derives username tokens without relying on whoami", () => {
|
||||
const tokens = resolveDynamicForbiddenTokens(
|
||||
{ USER: "paperclip", LOGNAME: "paperclip", USERNAME: "pc" },
|
||||
{
|
||||
userInfo: () => ({ username: "paperclip" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(tokens).toEqual(["paperclip", "pc"]);
|
||||
});
|
||||
|
||||
it("falls back cleanly when user resolution fails", () => {
|
||||
const tokens = resolveDynamicForbiddenTokens(
|
||||
{},
|
||||
{
|
||||
userInfo: () => {
|
||||
throw new Error("missing user");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(tokens).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges dynamic and file-based forbidden tokens", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
|
||||
const tokensFile = path.join(os.tmpdir(), `forbidden-tokens-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tokensFile, "# comment\npaperclip\ncustom-token\n");
|
||||
|
||||
try {
|
||||
const tokens = resolveForbiddenTokens(tokensFile, { USER: "paperclip" }, {
|
||||
userInfo: () => ({ username: "paperclip" }),
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(["paperclip", "custom-token"]);
|
||||
} finally {
|
||||
fs.unlinkSync(tokensFile);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports matches without leaking which token was searched", () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("server/file.ts:1:found\n")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("not found");
|
||||
});
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
|
||||
const exitCode = runForbiddenTokenCheck({
|
||||
repoRoot: "/repo",
|
||||
tokens: ["paperclip", "custom-token"],
|
||||
exec,
|
||||
log,
|
||||
error,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(exec).toHaveBeenCalledTimes(2);
|
||||
expect(error).toHaveBeenCalledWith("ERROR: Forbidden tokens found in tracked files:\n");
|
||||
expect(error).toHaveBeenCalledWith(" server/file.ts:1:found");
|
||||
expect(error).toHaveBeenCalledWith("\nBuild blocked. Remove the forbidden token(s) before publishing.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { testEnvironment } from "@paperclipai/adapter-gemini-local/server";
|
||||
|
||||
async function writeFakeGeminiCommand(binDir: string, argsCapturePath: string): Promise<string> {
|
||||
const commandPath = path.join(binDir, "gemini");
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const outPath = process.env.PAPERCLIP_TEST_ARGS_PATH;
|
||||
if (outPath) {
|
||||
fs.writeFileSync(outPath, JSON.stringify(process.argv.slice(2)), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "hello",
|
||||
}));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
return commandPath;
|
||||
}
|
||||
|
||||
async function writeQuotaGeminiCommand(binDir: string): Promise<string> {
|
||||
const commandPath = path.join(binDir, "gemini");
|
||||
const script = `#!/usr/bin/env node
|
||||
if (process.argv.includes("--help")) {
|
||||
process.exit(0);
|
||||
}
|
||||
console.error("429 RESOURCE_EXHAUSTED: You exceeded your current quota and billing details.");
|
||||
process.exit(1);
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
return commandPath;
|
||||
}
|
||||
|
||||
describe("gemini_local environment diagnostics", () => {
|
||||
it("creates a missing working directory when cwd is absolute", async () => {
|
||||
const cwd = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-gemini-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
"workspace",
|
||||
);
|
||||
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "gemini_cwd_valid")).toBe(true);
|
||||
expect(result.checks.some((check) => check.level === "error")).toBe(false);
|
||||
const stats = await fs.stat(cwd);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("passes model and yolo flags to the hello probe", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-gemini-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const binDir = path.join(root, "bin");
|
||||
const cwd = path.join(root, "workspace");
|
||||
const argsCapturePath = path.join(root, "args.json");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeFakeGeminiCommand(binDir, argsCapturePath);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
command: "gemini",
|
||||
cwd,
|
||||
model: "gemini-2.5-pro",
|
||||
yolo: true,
|
||||
env: {
|
||||
GEMINI_API_KEY: "test-key",
|
||||
PAPERCLIP_TEST_ARGS_PATH: argsCapturePath,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe("fail");
|
||||
const args = JSON.parse(await fs.readFile(argsCapturePath, "utf8")) as string[];
|
||||
expect(args).toContain("--model");
|
||||
expect(args).toContain("gemini-2.5-pro");
|
||||
expect(args).toContain("--approval-mode");
|
||||
expect(args).toContain("yolo");
|
||||
expect(args).toContain("--prompt");
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("classifies quota exhaustion as a quota warning instead of a generic failure", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-gemini-local-quota-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const binDir = path.join(root, "bin");
|
||||
const cwd = path.join(root, "workspace");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeQuotaGeminiCommand(binDir);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
command: "gemini",
|
||||
cwd,
|
||||
env: {
|
||||
GEMINI_API_KEY: "test-key",
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("warn");
|
||||
expect(result.checks.some((check) => check.code === "gemini_hello_probe_quota_exhausted")).toBe(true);
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { 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", () => {
|
||||
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" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "gemini-session-1",
|
||||
usage: {
|
||||
promptTokenCount: 12,
|
||||
cachedContentTokenCount: 3,
|
||||
candidatesTokenCount: 7,
|
||||
},
|
||||
total_cost_usd: 0.00123,
|
||||
result: "done",
|
||||
}),
|
||||
JSON.stringify({ type: "error", message: "model access denied" }),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseGeminiJsonl(stdout);
|
||||
expect(parsed.sessionId).toBe("gemini-session-1");
|
||||
expect(parsed.summary).toBe("hello");
|
||||
expect(parsed.usage).toEqual({
|
||||
inputTokens: 12,
|
||||
cachedInputTokens: 3,
|
||||
outputTokens: 7,
|
||||
});
|
||||
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
|
||||
expect(parsed.errorMessage).toBe("model access denied");
|
||||
});
|
||||
|
||||
it("extracts structured questions", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "output_text", text: "I have a question." },
|
||||
{
|
||||
type: "question",
|
||||
prompt: "Which model?",
|
||||
choices: [
|
||||
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||
{ key: "flash", label: "Gemini Flash" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseGeminiJsonl(stdout);
|
||||
expect(parsed.summary).toBe("I have a question.");
|
||||
expect(parsed.question).toEqual({
|
||||
prompt: "Which model?",
|
||||
choices: [
|
||||
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||
{ key: "flash", label: "Gemini Flash", description: undefined },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini_local stale session detection", () => {
|
||||
it("treats missing session messages as an unknown session error", () => {
|
||||
expect(isGeminiUnknownSessionError("", "unknown session id abc")).toBe(true);
|
||||
expect(isGeminiUnknownSessionError("", "checkpoint latest not found")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini_local ui stdout parser", () => {
|
||||
it("parses assistant, thinking, 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" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
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,
|
||||
},
|
||||
total_cost_usd: 0.00042,
|
||||
is_error: false,
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "Done",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cachedTokens: 2,
|
||||
costUsd: 0.00042,
|
||||
subtype: "success",
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
describe("gemini_local cli formatter", () => {
|
||||
it("prints init, assistant, result, and error events", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
let joined = "";
|
||||
|
||||
try {
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
|
||||
false,
|
||||
);
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
cachedContentTokenCount: 2,
|
||||
},
|
||||
total_cost_usd: 0.00042,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({ type: "error", message: "boom" }),
|
||||
false,
|
||||
);
|
||||
joined = spy.mock.calls.map((call) => stripAnsi(call.join(" "))).join("\n");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(joined).toContain("Gemini init");
|
||||
expect(joined).toContain("assistant: hello");
|
||||
expect(joined).toContain("tokens: in=10 out=5 cached=2 cost=$0.000420");
|
||||
expect(joined).toContain("error: boom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execute } from "@paperclipai/adapter-gemini-local/server";
|
||||
|
||||
async function writeFakeGeminiCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
paperclipEnvKeys: Object.keys(process.env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort(),
|
||||
};
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "gemini-session-1",
|
||||
model: "gemini-2.5-pro",
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "gemini-session-1",
|
||||
result: "ok",
|
||||
}));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
type CapturePayload = {
|
||||
argv: string[];
|
||||
paperclipEnvKeys: string[];
|
||||
};
|
||||
|
||||
describe("gemini execute", () => {
|
||||
it("passes prompt via --prompt and injects paperclip env vars", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "gemini");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeGeminiCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
let invocationPrompt = "";
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Gemini Coder",
|
||||
adapterType: "gemini_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model: "gemini-2.5-pro",
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
invocationPrompt = meta.prompt ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toContain("--output-format");
|
||||
expect(capture.argv).toContain("stream-json");
|
||||
expect(capture.argv).toContain("--prompt");
|
||||
expect(capture.argv).toContain("--approval-mode");
|
||||
expect(capture.argv).toContain("yolo");
|
||||
const promptFlagIndex = capture.argv.indexOf("--prompt");
|
||||
const promptArg = promptFlagIndex >= 0 ? capture.argv[promptFlagIndex + 1] : "";
|
||||
expect(promptArg).toContain("Follow the paperclip heartbeat.");
|
||||
expect(promptArg).toContain("Paperclip runtime note:");
|
||||
expect(capture.paperclipEnvKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"PAPERCLIP_AGENT_ID",
|
||||
"PAPERCLIP_API_KEY",
|
||||
"PAPERCLIP_API_URL",
|
||||
"PAPERCLIP_COMPANY_ID",
|
||||
"PAPERCLIP_RUN_ID",
|
||||
]),
|
||||
);
|
||||
expect(invocationPrompt).toContain("Paperclip runtime note:");
|
||||
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
|
||||
expect(invocationPrompt).toContain("Paperclip API access note:");
|
||||
expect(invocationPrompt).toContain("run_shell_command");
|
||||
expect(result.question).toBeNull();
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("always passes --approval-mode yolo", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-yolo-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "gemini");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeGeminiCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
await execute({
|
||||
runId: "run-yolo",
|
||||
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,
|
||||
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
||||
},
|
||||
context: {},
|
||||
authToken: "t",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toContain("--approval-mode");
|
||||
expect(capture.argv).toContain("yolo");
|
||||
expect(capture.argv).not.toContain("--policy");
|
||||
expect(capture.argv).not.toContain("--allow-all");
|
||||
expect(capture.argv).not.toContain("--allow-read");
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listGeminiSkills,
|
||||
syncGeminiSkills,
|
||||
} from "@paperclipai/adapter-gemini-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("gemini local skill sync", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the Gemini skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-gemini-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listGeminiSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toContain(paperclipKey);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||
|
||||
const after = await syncGeminiSkills(ctx, [paperclipKey]);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-gemini-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncGeminiSkills(configuredCtx, [paperclipKey]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncGeminiSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toContain(paperclipKey);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { healthRoutes } from "../routes/health.js";
|
||||
import { serverVersion } from "../version.js";
|
||||
|
||||
describe("GET /health", () => {
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes());
|
||||
|
||||
it("returns 200 with status ok", async () => {
|
||||
const res = await request(app).get("/health");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ status: "ok", version: serverVersion });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
companies,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { runningProcesses } from "../adapters/index.ts";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-recovery-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
return { connectionString, instance, dataDir };
|
||||
}
|
||||
|
||||
function spawnAliveProcess() {
|
||||
return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
|
||||
describe("heartbeat orphaned process recovery", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
let dataDir = "";
|
||||
const childProcesses = new Set<ChildProcess>();
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startTempDatabase();
|
||||
db = createDb(started.connectionString);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
runningProcesses.clear();
|
||||
for (const child of childProcesses) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
childProcesses.clear();
|
||||
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 () => {
|
||||
for (const child of childProcesses) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
childProcesses.clear();
|
||||
runningProcesses.clear();
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function seedRunFixture(input?: {
|
||||
adapterType?: string;
|
||||
runStatus?: "running" | "queued" | "failed";
|
||||
processPid?: number | null;
|
||||
processLossRetryCount?: number;
|
||||
includeIssue?: boolean;
|
||||
runErrorCode?: string | null;
|
||||
runError?: string | null;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const wakeupRequestId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const now = new Date("2026-03-19T00: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: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "paused",
|
||||
adapterType: input?.adapterType ?? "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
id: wakeupRequestId,
|
||||
companyId,
|
||||
agentId,
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: input?.includeIssue === false ? {} : { issueId },
|
||||
status: "claimed",
|
||||
runId,
|
||||
claimedAt: now,
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
status: input?.runStatus ?? "running",
|
||||
wakeupRequestId,
|
||||
contextSnapshot: input?.includeIssue === false ? {} : { issueId },
|
||||
processPid: input?.processPid ?? null,
|
||||
processLossRetryCount: input?.processLossRetryCount ?? 0,
|
||||
errorCode: input?.runErrorCode ?? null,
|
||||
error: input?.runError ?? null,
|
||||
startedAt: now,
|
||||
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
if (input?.includeIssue !== false) {
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Recover local adapter after lost process",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
checkoutRunId: runId,
|
||||
executionRunId: runId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
}
|
||||
|
||||
return { companyId, agentId, runId, wakeupRequestId, issueId };
|
||||
}
|
||||
|
||||
it("keeps a local run active when the recorded pid is still alive", async () => {
|
||||
const child = spawnAliveProcess();
|
||||
childProcesses.add(child);
|
||||
expect(child.pid).toBeTypeOf("number");
|
||||
|
||||
const { runId, wakeupRequestId } = await seedRunFixture({
|
||||
processPid: child.pid ?? null,
|
||||
includeIssue: false,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reapOrphanedRuns();
|
||||
expect(result.reaped).toBe(0);
|
||||
|
||||
const run = await heartbeat.getRun(runId);
|
||||
expect(run?.status).toBe("running");
|
||||
expect(run?.errorCode).toBe("process_detached");
|
||||
expect(run?.error).toContain(String(child.pid));
|
||||
|
||||
const wakeup = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.status).toBe("claimed");
|
||||
});
|
||||
|
||||
it("queues exactly one retry when the recorded local pid is dead", async () => {
|
||||
const { agentId, runId, issueId } = await seedRunFixture({
|
||||
processPid: 999_999_999,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reapOrphanedRuns();
|
||||
expect(result.reaped).toBe(1);
|
||||
expect(result.runIds).toEqual([runId]);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(2);
|
||||
|
||||
const failedRun = runs.find((row) => row.id === runId);
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect(failedRun?.status).toBe("failed");
|
||||
expect(failedRun?.errorCode).toBe("process_lost");
|
||||
expect(retryRun?.status).toBe("queued");
|
||||
expect(retryRun?.retryOfRunId).toBe(runId);
|
||||
expect(retryRun?.processLossRetryCount).toBe(1);
|
||||
|
||||
const issue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBe(retryRun?.id ?? null);
|
||||
expect(issue?.checkoutRunId).toBe(runId);
|
||||
});
|
||||
|
||||
it("does not queue a second retry after the first process-loss retry was already used", async () => {
|
||||
const { agentId, runId, issueId } = await seedRunFixture({
|
||||
processPid: 999_999_999,
|
||||
processLossRetryCount: 1,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reapOrphanedRuns();
|
||||
expect(result.reaped).toBe(1);
|
||||
expect(result.runIds).toEqual([runId]);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("failed");
|
||||
|
||||
const issue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBeNull();
|
||||
expect(issue?.checkoutRunId).toBe(runId);
|
||||
});
|
||||
|
||||
it("clears the detached warning when the run reports activity again", async () => {
|
||||
const { runId } = await seedRunFixture({
|
||||
includeIssue: false,
|
||||
runErrorCode: "process_detached",
|
||||
runError: "Lost in-memory process handle, but child pid 123 is still alive",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const updated = await heartbeat.reportRunActivity(runId);
|
||||
expect(updated?.errorCode).toBeNull();
|
||||
expect(updated?.error).toBeNull();
|
||||
|
||||
const run = await heartbeat.getRun(runId);
|
||||
expect(run?.errorCode).toBeNull();
|
||||
expect(run?.error).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { summarizeHeartbeatRunResultJson } from "../services/heartbeat-run-summary.js";
|
||||
|
||||
describe("summarizeHeartbeatRunResultJson", () => {
|
||||
it("truncates text fields and preserves cost aliases", () => {
|
||||
const summary = summarizeHeartbeatRunResultJson({
|
||||
summary: "a".repeat(600),
|
||||
result: "ok",
|
||||
message: "done",
|
||||
error: "failed",
|
||||
total_cost_usd: 1.23,
|
||||
cost_usd: 0.45,
|
||||
costUsd: 0.67,
|
||||
nested: { ignored: true },
|
||||
});
|
||||
|
||||
expect(summary).toEqual({
|
||||
summary: "a".repeat(500),
|
||||
result: "ok",
|
||||
message: "done",
|
||||
error: "failed",
|
||||
total_cost_usd: 1.23,
|
||||
cost_usd: 0.45,
|
||||
costUsd: 0.67,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-object and irrelevant payloads", () => {
|
||||
expect(summarizeHeartbeatRunResultJson(null)).toBeNull();
|
||||
expect(summarizeHeartbeatRunResultJson(["nope"] as unknown as Record<string, unknown>)).toBeNull();
|
||||
expect(summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { agents } from "@paperclipai/db";
|
||||
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import {
|
||||
buildExplicitResumeSessionOverride,
|
||||
formatRuntimeWorkspaceWarningLog,
|
||||
prioritizeProjectWorkspaceCandidatesForRun,
|
||||
parseSessionCompactionPolicy,
|
||||
resolveRuntimeSessionParamsForWorkspace,
|
||||
shouldResetTaskSessionForWake,
|
||||
type ResolvedWorkspaceForRun,
|
||||
} from "../services/heartbeat.ts";
|
||||
|
||||
function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}): ResolvedWorkspaceForRun {
|
||||
return {
|
||||
cwd: "/tmp/project",
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
workspaceHints: [],
|
||||
warnings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
name: "Agent",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "running",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType,
|
||||
adapterConfig: {},
|
||||
runtimeConfig,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
permissions: {},
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as typeof agents.$inferSelect;
|
||||
}
|
||||
|
||||
describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
||||
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
|
||||
const agentId = "agent-123";
|
||||
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
|
||||
|
||||
const result = resolveRuntimeSessionParamsForWorkspace({
|
||||
agentId,
|
||||
previousSessionParams: {
|
||||
sessionId: "session-1",
|
||||
cwd: fallbackCwd,
|
||||
workspaceId: "workspace-1",
|
||||
},
|
||||
resolvedWorkspace: buildResolvedWorkspace({ cwd: "/tmp/new-project-cwd" }),
|
||||
});
|
||||
|
||||
expect(result.sessionParams).toMatchObject({
|
||||
sessionId: "session-1",
|
||||
cwd: "/tmp/new-project-cwd",
|
||||
workspaceId: "workspace-1",
|
||||
});
|
||||
expect(result.warning).toContain("Attempting to resume session");
|
||||
});
|
||||
|
||||
it("does not migrate when previous session cwd is not the fallback workspace", () => {
|
||||
const result = resolveRuntimeSessionParamsForWorkspace({
|
||||
agentId: "agent-123",
|
||||
previousSessionParams: {
|
||||
sessionId: "session-1",
|
||||
cwd: "/tmp/some-other-cwd",
|
||||
workspaceId: "workspace-1",
|
||||
},
|
||||
resolvedWorkspace: buildResolvedWorkspace({ cwd: "/tmp/new-project-cwd" }),
|
||||
});
|
||||
|
||||
expect(result.sessionParams).toEqual({
|
||||
sessionId: "session-1",
|
||||
cwd: "/tmp/some-other-cwd",
|
||||
workspaceId: "workspace-1",
|
||||
});
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
|
||||
it("does not migrate when resolved workspace id differs from previous session workspace id", () => {
|
||||
const agentId = "agent-123";
|
||||
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
|
||||
|
||||
const result = resolveRuntimeSessionParamsForWorkspace({
|
||||
agentId,
|
||||
previousSessionParams: {
|
||||
sessionId: "session-1",
|
||||
cwd: fallbackCwd,
|
||||
workspaceId: "workspace-1",
|
||||
},
|
||||
resolvedWorkspace: buildResolvedWorkspace({
|
||||
cwd: "/tmp/new-project-cwd",
|
||||
workspaceId: "workspace-2",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.sessionParams).toEqual({
|
||||
sessionId: "session-1",
|
||||
cwd: fallbackCwd,
|
||||
workspaceId: "workspace-1",
|
||||
});
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldResetTaskSessionForWake", () => {
|
||||
it("resets session context on assignment wake", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves session context on timer heartbeats", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves session context on manual on-demand invokes by default", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForWake({
|
||||
wakeSource: "on_demand",
|
||||
wakeTriggerDetail: "manual",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resets session context when a fresh session is explicitly requested", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForWake({
|
||||
wakeSource: "on_demand",
|
||||
wakeTriggerDetail: "manual",
|
||||
forceFreshSession: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reset session context on mention wake comment", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForWake({
|
||||
wakeReason: "issue_comment_mentioned",
|
||||
wakeCommentId: "comment-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reset session context when commentId is present", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForWake({
|
||||
wakeReason: "issue_commented",
|
||||
commentId: "comment-2",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reset for comment wakes", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_commented" })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reset when wake reason is missing", () => {
|
||||
expect(shouldResetTaskSessionForWake({})).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reset session context on callback on-demand invokes", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForWake({
|
||||
wakeSource: "on_demand",
|
||||
wakeTriggerDetail: "callback",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildExplicitResumeSessionOverride", () => {
|
||||
it("reuses saved task session params when they belong to the selected failed run", () => {
|
||||
const result = buildExplicitResumeSessionOverride({
|
||||
resumeFromRunId: "run-1",
|
||||
resumeRunSessionIdBefore: "session-before",
|
||||
resumeRunSessionIdAfter: "session-after",
|
||||
taskSession: {
|
||||
sessionParamsJson: {
|
||||
sessionId: "session-after",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
sessionDisplayId: "session-after",
|
||||
lastRunId: "run-1",
|
||||
},
|
||||
sessionCodec: codexSessionCodec,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sessionDisplayId: "session-after",
|
||||
sessionParams: {
|
||||
sessionId: "session-after",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the selected run session id when no matching task session params are available", () => {
|
||||
const result = buildExplicitResumeSessionOverride({
|
||||
resumeFromRunId: "run-1",
|
||||
resumeRunSessionIdBefore: "session-before",
|
||||
resumeRunSessionIdAfter: "session-after",
|
||||
taskSession: {
|
||||
sessionParamsJson: {
|
||||
sessionId: "other-session",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
sessionDisplayId: "other-session",
|
||||
lastRunId: "run-2",
|
||||
},
|
||||
sessionCodec: codexSessionCodec,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sessionDisplayId: "session-after",
|
||||
sessionParams: {
|
||||
sessionId: "session-after",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRuntimeWorkspaceWarningLog", () => {
|
||||
it("emits informational workspace warnings on stdout", () => {
|
||||
expect(formatRuntimeWorkspaceWarningLog("Using fallback workspace")).toEqual({
|
||||
stream: "stdout",
|
||||
chunk: "[paperclip] Using fallback workspace\n",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("prioritizeProjectWorkspaceCandidatesForRun", () => {
|
||||
it("moves the explicitly selected workspace to the front", () => {
|
||||
const rows = [
|
||||
{ id: "workspace-1", cwd: "/tmp/one" },
|
||||
{ id: "workspace-2", cwd: "/tmp/two" },
|
||||
{ id: "workspace-3", cwd: "/tmp/three" },
|
||||
];
|
||||
|
||||
expect(
|
||||
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-2").map((row) => row.id),
|
||||
).toEqual(["workspace-2", "workspace-1", "workspace-3"]);
|
||||
});
|
||||
|
||||
it("keeps the original order when no preferred workspace is selected", () => {
|
||||
const rows = [
|
||||
{ id: "workspace-1" },
|
||||
{ id: "workspace-2" },
|
||||
];
|
||||
|
||||
expect(
|
||||
prioritizeProjectWorkspaceCandidatesForRun(rows, null).map((row) => row.id),
|
||||
).toEqual(["workspace-1", "workspace-2"]);
|
||||
});
|
||||
|
||||
it("keeps the original order when the selected workspace is missing", () => {
|
||||
const rows = [
|
||||
{ id: "workspace-1" },
|
||||
{ id: "workspace-2" },
|
||||
];
|
||||
|
||||
expect(
|
||||
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-9").map((row) => row.id),
|
||||
).toEqual(["workspace-1", "workspace-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSessionCompactionPolicy", () => {
|
||||
it("disables Paperclip-managed rotation by default for codex and claude local", () => {
|
||||
expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 0,
|
||||
maxRawInputTokens: 0,
|
||||
maxSessionAgeHours: 0,
|
||||
});
|
||||
expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 0,
|
||||
maxRawInputTokens: 0,
|
||||
maxSessionAgeHours: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps conservative defaults for adapters without confirmed native compaction", () => {
|
||||
expect(parseSessionCompactionPolicy(buildAgent("cursor"))).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 200,
|
||||
maxRawInputTokens: 2_000_000,
|
||||
maxSessionAgeHours: 72,
|
||||
});
|
||||
expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 200,
|
||||
maxRawInputTokens: 2_000_000,
|
||||
maxSessionAgeHours: 72,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets explicit agent overrides win over adapter defaults", () => {
|
||||
expect(
|
||||
parseSessionCompactionPolicy(
|
||||
buildAgent("codex_local", {
|
||||
heartbeat: {
|
||||
sessionCompaction: {
|
||||
maxSessionRuns: 25,
|
||||
maxRawInputTokens: 500_000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 25,
|
||||
maxRawInputTokens: 500_000,
|
||||
maxSessionAgeHours: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { notifyHireApproved } from "../services/hire-hook.js";
|
||||
|
||||
// Mock the registry so we control whether the adapter has onHireApproved and what it does.
|
||||
vi.mock("../adapters/registry.js", () => ({
|
||||
findServerAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { findServerAdapter } = await import("../adapters/registry.js");
|
||||
const { logActivity } = await import("../services/activity-log.js");
|
||||
|
||||
function mockDbWithAgent(agent: { id: string; companyId: string; name: string; adapterType: string; adapterConfig?: Record<string, unknown> }): Db {
|
||||
return {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: agent.id,
|
||||
companyId: agent.companyId,
|
||||
name: agent.name,
|
||||
adapterType: agent.adapterType,
|
||||
adapterConfig: agent.adapterConfig ?? {},
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("notifyHireApproved", () => {
|
||||
it("writes success activity when adapter hook returns ok", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
||||
} as any);
|
||||
|
||||
const db = mockDbWithAgent({
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
notifyHireApproved(db, {
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
source: "approval",
|
||||
sourceId: "ap1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "hire_hook.succeeded",
|
||||
entityId: "a1",
|
||||
details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw_gateway" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when agent is not found", async () => {
|
||||
const db = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => Promise.resolve([]),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Db;
|
||||
|
||||
await expect(
|
||||
notifyHireApproved(db, {
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
source: "join_request",
|
||||
sourceId: "jr1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(findServerAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when adapter has no onHireApproved", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({ type: "process" } as any);
|
||||
|
||||
const db = mockDbWithAgent({
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "Agent",
|
||||
adapterType: "process",
|
||||
});
|
||||
|
||||
await expect(
|
||||
notifyHireApproved(db, {
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
source: "approval",
|
||||
sourceId: "ap1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(findServerAdapter).toHaveBeenCalledWith("process");
|
||||
expect(logActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
|
||||
} as any);
|
||||
|
||||
const db = mockDbWithAgent({
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
notifyHireApproved(db, {
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
source: "join_request",
|
||||
sourceId: "jr1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "hire_hook.failed",
|
||||
entityId: "a1",
|
||||
details: expect.objectContaining({ source: "join_request", sourceId: "jr1", error: "HTTP 500" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||
} as any);
|
||||
|
||||
const db = mockDbWithAgent({
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
notifyHireApproved(db, {
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
source: "join_request",
|
||||
sourceId: "jr1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "hire_hook.error",
|
||||
entityId: "a1",
|
||||
details: expect.objectContaining({ source: "join_request", sourceId: "jr1", error: "Network error" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { instanceSettingsRoutes } from "../routes/instance-settings.js";
|
||||
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(),
|
||||
getExperimental: vi.fn(),
|
||||
updateGeneral: vi.fn(),
|
||||
updateExperimental: vi.fn(),
|
||||
listCompanyIds: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createApp(actor: any) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", instanceSettingsRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("instance settings routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
||||
censorUsernameInLogs: false,
|
||||
});
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
});
|
||||
mockInstanceSettingsService.updateGeneral.mockResolvedValue({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: true,
|
||||
},
|
||||
});
|
||||
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
|
||||
id: "instance-settings-1",
|
||||
experimental: {
|
||||
enableIsolatedWorkspaces: true,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
},
|
||||
});
|
||||
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
|
||||
});
|
||||
|
||||
it("allows local board users to read and update experimental settings", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
const getRes = await request(app).get("/api/instance/settings/experimental");
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body).toEqual({
|
||||
enableIsolatedWorkspaces: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
});
|
||||
|
||||
const patchRes = await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableIsolatedWorkspaces: true });
|
||||
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
||||
enableIsolatedWorkspaces: true,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("allows local board users to update guarded dev-server auto-restart", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ autoRestartDevServerWhenIdle: true })
|
||||
.expect(200);
|
||||
|
||||
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local board users to read and update general settings", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
const getRes = await request(app).get("/api/instance/settings/general");
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body).toEqual({ censorUsernameInLogs: false });
|
||||
|
||||
const patchRes = await request(app)
|
||||
.patch("/api/instance/settings/general")
|
||||
.send({ censorUsernameInLogs: true });
|
||||
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
|
||||
censorUsernameInLogs: true,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects non-admin board users", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/instance/settings/general");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent callers", async () => {
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/instance/settings/general")
|
||||
.send({ censorUsernameInLogs: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
normalizeAgentDefaultsForJoin,
|
||||
} from "../routes/access.js";
|
||||
|
||||
describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => {
|
||||
it("leaves non-gateway payloads unchanged", () => {
|
||||
const defaultsPayload = { command: "echo hello" };
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "process",
|
||||
defaultsPayload,
|
||||
inboundOpenClawAuthHeader: "ignored-token",
|
||||
});
|
||||
|
||||
expect(result).toEqual(defaultsPayload);
|
||||
});
|
||||
|
||||
it("normalizes wrapped x-openclaw-token header", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": {
|
||||
value: "gateway-token-1234567890",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts inbound x-openclaw-token for gateway joins", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
},
|
||||
inboundOpenClawTokenHeader: "gateway-token-1234567890",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("derives x-openclaw-token from authorization header", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
authorization: "Bearer gateway-token-1234567890",
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
authorization: "Bearer gateway-token-1234567890",
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => {
|
||||
it("generates persistent device key when device auth is enabled", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: false,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
|
||||
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
|
||||
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
|
||||
});
|
||||
|
||||
it("does not generate device key when disableDeviceAuth=true", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: true,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
|
||||
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
canReplayOpenClawGatewayInviteAccept,
|
||||
mergeJoinDefaultsPayloadForReplay,
|
||||
} from "../routes/access.js";
|
||||
|
||||
describe("canReplayOpenClawGatewayInviteAccept", () => {
|
||||
it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => {
|
||||
expect(
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "pending_approval",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "approved",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "rejected",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "human",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "pending_approval",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeJoinDefaultsPayloadForReplay", () => {
|
||||
it("merges replay payloads and allows gateway token override", () => {
|
||||
const merged = mergeJoinDefaultsPayloadForReplay(
|
||||
{
|
||||
url: "ws://old.example:18789",
|
||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||
headers: {
|
||||
"x-openclaw-token": "old-token-1234567890",
|
||||
"x-custom": "keep-me",
|
||||
},
|
||||
},
|
||||
{
|
||||
paperclipApiUrl: "https://paperclip.example.com",
|
||||
headers: {
|
||||
"x-openclaw-token": "new-token-1234567890",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const normalized = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: merged,
|
||||
inboundOpenClawAuthHeader: null,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(normalized.url).toBe("ws://old.example:18789");
|
||||
expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com");
|
||||
expect(normalized.headers).toMatchObject({
|
||||
"x-openclaw-token": "new-token-1234567890",
|
||||
"x-custom": "keep-me",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { companyInviteExpiresAt } from "../routes/access.js";
|
||||
|
||||
describe("companyInviteExpiresAt", () => {
|
||||
it("sets invite expiration to 10 minutes after invite creation time", () => {
|
||||
const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z");
|
||||
const expiresAt = companyInviteExpiresAt(createdAtMs);
|
||||
expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agentJoinGrantsFromDefaults } from "../routes/access.js";
|
||||
|
||||
describe("agentJoinGrantsFromDefaults", () => {
|
||||
it("adds tasks:assign when invite defaults do not specify agent grants", () => {
|
||||
expect(agentJoinGrantsFromDefaults(null)).toEqual([
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves invite agent grants and appends tasks:assign", () => {
|
||||
expect(
|
||||
agentJoinGrantsFromDefaults({
|
||||
agent: {
|
||||
grants: [
|
||||
{
|
||||
permissionKey: "agents:create",
|
||||
scope: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
permissionKey: "agents:create",
|
||||
scope: null,
|
||||
},
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate tasks:assign when invite defaults already include it", () => {
|
||||
expect(
|
||||
agentJoinGrantsFromDefaults({
|
||||
agent: {
|
||||
grants: [
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: { projectId: "project-1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: { projectId: "project-1" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveJoinRequestAgentManagerId } from "../routes/access.js";
|
||||
|
||||
describe("resolveJoinRequestAgentManagerId", () => {
|
||||
it("returns null when no CEO exists in the company agent list", () => {
|
||||
const managerId = resolveJoinRequestAgentManagerId([
|
||||
{ id: "a1", role: "cto", reportsTo: null },
|
||||
{ id: "a2", role: "engineer", reportsTo: "a1" },
|
||||
]);
|
||||
|
||||
expect(managerId).toBeNull();
|
||||
});
|
||||
|
||||
it("selects the root CEO when available", () => {
|
||||
const managerId = resolveJoinRequestAgentManagerId([
|
||||
{ id: "ceo-child", role: "ceo", reportsTo: "manager-1" },
|
||||
{ id: "manager-1", role: "cto", reportsTo: null },
|
||||
{ id: "ceo-root", role: "ceo", reportsTo: null },
|
||||
]);
|
||||
|
||||
expect(managerId).toBe("ceo-root");
|
||||
});
|
||||
|
||||
it("falls back to the first CEO when no root CEO is present", () => {
|
||||
const managerId = resolveJoinRequestAgentManagerId([
|
||||
{ id: "ceo-1", role: "ceo", reportsTo: "mgr" },
|
||||
{ id: "ceo-2", role: "ceo", reportsTo: "mgr" },
|
||||
{ id: "mgr", role: "cto", reportsTo: null },
|
||||
]);
|
||||
|
||||
expect(managerId).toBe("ceo-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Request } from "express";
|
||||
import { buildInviteOnboardingTextDocument } from "../routes/access.js";
|
||||
|
||||
function buildReq(host: string): Request {
|
||||
return {
|
||||
protocol: "http",
|
||||
header(name: string) {
|
||||
if (name.toLowerCase() === "host") return host;
|
||||
return undefined;
|
||||
},
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe("buildInviteOnboardingTextDocument", () => {
|
||||
it("renders a plain-text onboarding doc with expected endpoint references", () => {
|
||||
const req = buildReq("localhost:3100");
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "agent",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||
} as const;
|
||||
|
||||
const text = buildInviteOnboardingTextDocument(req, "token-123", invite as any, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(text).toContain("Paperclip OpenClaw Gateway Onboarding");
|
||||
expect(text).toContain("/api/invites/token-123/accept");
|
||||
expect(text).toContain("/api/join-requests/{requestId}/claim-api-key");
|
||||
expect(text).toContain("/api/invites/token-123/onboarding.txt");
|
||||
expect(text).toContain("Suggested Paperclip base URLs to try");
|
||||
expect(text).toContain("http://localhost:3100");
|
||||
expect(text).toContain("host.docker.internal");
|
||||
expect(text).toContain("paperclipApiUrl");
|
||||
expect(text).toContain("adapterType \"openclaw_gateway\"");
|
||||
expect(text).toContain("headers.x-openclaw-token");
|
||||
expect(text).toContain("Do NOT use /v1/responses or /hooks/*");
|
||||
expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl");
|
||||
expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json");
|
||||
expect(text).toContain("PAPERCLIP_API_KEY");
|
||||
expect(text).toContain("saved token field");
|
||||
expect(text).toContain("Gateway token unexpectedly short");
|
||||
});
|
||||
|
||||
it("includes loopback diagnostics for authenticated/private onboarding", () => {
|
||||
const req = buildReq("localhost:3100");
|
||||
const invite = {
|
||||
id: "invite-2",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "both",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||
} as const;
|
||||
|
||||
const text = buildInviteOnboardingTextDocument(req, "token-456", invite as any, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(text).toContain("Connectivity diagnostics");
|
||||
expect(text).toContain("loopback hostname");
|
||||
expect(text).toContain("If none are reachable");
|
||||
});
|
||||
|
||||
it("includes inviter message in the onboarding text when provided", () => {
|
||||
const req = buildReq("localhost:3100");
|
||||
const invite = {
|
||||
id: "invite-3",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "agent",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: {
|
||||
agentMessage: "Please join as our QA lead and prioritize flaky test triage first.",
|
||||
},
|
||||
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||
} as const;
|
||||
|
||||
const text = buildInviteOnboardingTextDocument(req, "token-789", invite as any, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(text).toContain("Message from inviter");
|
||||
expect(text).toContain("prioritize flaky test triage first");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
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(status: "todo" | "done") {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
status,
|
||||
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-580",
|
||||
title: "Comment reopen default",
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue comment reopen routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
body: "hello",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
});
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("treats reopen=true as a no-op when the issue is already open", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue("todo"),
|
||||
...patch,
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.updated",
|
||||
details: expect.not.objectContaining({ reopened: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reopens closed issues via the PATCH comment path", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue("done"),
|
||||
...patch,
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
status: "todo",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.updated",
|
||||
details: expect.objectContaining({
|
||||
reopened: true,
|
||||
reopenedFrom: "done",
|
||||
status: "todo",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveIssueGoalId,
|
||||
resolveNextIssueGoalId,
|
||||
} from "../services/issue-goal-fallback.ts";
|
||||
|
||||
describe("issue goal fallback", () => {
|
||||
it("assigns the company goal when creating an issue without project or goal", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-1");
|
||||
});
|
||||
|
||||
it("keeps an explicit goal when creating an issue", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: null,
|
||||
goalId: "goal-2",
|
||||
projectGoalId: "goal-3",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("inherits the project goal when creating a project-linked issue", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
projectGoalId: "goal-2",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("does not force a company goal when the project has no goal", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
projectGoalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("backfills the company goal on update for legacy no-project issues", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: null,
|
||||
currentGoalId: null,
|
||||
currentProjectGoalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-1");
|
||||
});
|
||||
|
||||
it("switches from the company fallback to the project goal when a project is added later", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: null,
|
||||
currentGoalId: "goal-1",
|
||||
currentProjectGoalId: null,
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
projectGoalId: "goal-2",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("backfills the project goal for legacy project-linked issues on update", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: "project-1",
|
||||
currentGoalId: null,
|
||||
currentProjectGoalId: "goal-2",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("preserves an explicit goal across project fallback changes", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: "project-1",
|
||||
currentGoalId: "goal-explicit",
|
||||
currentProjectGoalId: "goal-2",
|
||||
projectId: "project-2",
|
||||
projectGoalId: "goal-3",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-explicit");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldWakeAssigneeOnCheckout } from "../routes/issues-checkout-wakeup.js";
|
||||
|
||||
describe("shouldWakeAssigneeOnCheckout", () => {
|
||||
it("keeps wakeup behavior for board actors", () => {
|
||||
expect(
|
||||
shouldWakeAssigneeOnCheckout({
|
||||
actorType: "board",
|
||||
actorAgentId: null,
|
||||
checkoutAgentId: "agent-1",
|
||||
checkoutRunId: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("skips wakeup for agent self-checkout in an active run", () => {
|
||||
expect(
|
||||
shouldWakeAssigneeOnCheckout({
|
||||
actorType: "agent",
|
||||
actorAgentId: "agent-1",
|
||||
checkoutAgentId: "agent-1",
|
||||
checkoutRunId: "run-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still wakes when checkout run id is missing", () => {
|
||||
expect(
|
||||
shouldWakeAssigneeOnCheckout({
|
||||
actorType: "agent",
|
||||
actorAgentId: "agent-1",
|
||||
checkoutAgentId: "agent-1",
|
||||
checkoutRunId: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still wakes when agent checks out on behalf of another agent id", () => {
|
||||
expect(
|
||||
shouldWakeAssigneeOnCheckout({
|
||||
actorType: "agent",
|
||||
actorAgentId: "agent-1",
|
||||
checkoutAgentId: "agent-2",
|
||||
checkoutRunId: "run-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getAncestors: vi.fn(),
|
||||
findMentionedProjectIds: vi.fn(),
|
||||
getCommentCursor: vi.fn(),
|
||||
getComment: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockProjectService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
listByIds: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGoalService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getDefaultCompanyGoal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
documentService: () => ({
|
||||
getIssueDocumentPayload: vi.fn(async () => ({})),
|
||||
}),
|
||||
executionWorkspaceService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
goalService: () => mockGoalService,
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => mockProjectService,
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
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;
|
||||
}
|
||||
|
||||
const legacyProjectLinkedIssue = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-581",
|
||||
title: "Legacy onboarding task",
|
||||
description: "Seed the first CEO task",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
projectId: "22222222-2222-4222-8222-222222222222",
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
updatedAt: new Date("2026-03-24T12:00:00Z"),
|
||||
executionWorkspaceId: null,
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
};
|
||||
|
||||
const projectGoal = {
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
companyId: "company-1",
|
||||
title: "Launch the company",
|
||||
description: null,
|
||||
level: "company",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00Z"),
|
||||
};
|
||||
|
||||
describe("issue goal context routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
|
||||
mockIssueService.getAncestors.mockResolvedValue([]);
|
||||
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
|
||||
mockIssueService.getCommentCursor.mockResolvedValue({
|
||||
totalComments: 0,
|
||||
latestCommentId: null,
|
||||
latestCommentAt: null,
|
||||
});
|
||||
mockIssueService.getComment.mockResolvedValue(null);
|
||||
mockProjectService.getById.mockResolvedValue({
|
||||
id: legacyProjectLinkedIssue.projectId,
|
||||
companyId: "company-1",
|
||||
urlKey: "onboarding",
|
||||
goalId: projectGoal.id,
|
||||
goalIds: [projectGoal.id],
|
||||
goals: [{ id: projectGoal.id, title: projectGoal.title }],
|
||||
name: "Onboarding",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: "/tmp/company-1/project-1",
|
||||
effectiveLocalFolder: "/tmp/company-1/project-1",
|
||||
origin: "managed_checkout",
|
||||
},
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
archivedAt: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00Z"),
|
||||
});
|
||||
mockProjectService.listByIds.mockResolvedValue([]);
|
||||
mockGoalService.getById.mockImplementation(async (id: string) =>
|
||||
id === projectGoal.id ? projectGoal : null,
|
||||
);
|
||||
mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
|
||||
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.goalId).toBe(projectGoal.id);
|
||||
expect(res.body.goal).toEqual(
|
||||
expect.objectContaining({
|
||||
id: projectGoal.id,
|
||||
title: projectGoal.title,
|
||||
}),
|
||||
);
|
||||
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => {
|
||||
const res = await request(createApp()).get(
|
||||
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.issue.goalId).toBe(projectGoal.id);
|
||||
expect(res.body.goal).toEqual(
|
||||
expect.objectContaining({
|
||||
id: projectGoal.id,
|
||||
title: projectGoal.title,
|
||||
}),
|
||||
);
|
||||
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
applyPendingMigrations,
|
||||
companies,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
return { connectionString, dataDir, instance };
|
||||
}
|
||||
|
||||
describe("issueService.list participantAgentId", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
let dataDir = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startTempDatabase();
|
||||
db = createDb(started.connectionString);
|
||||
svc = issueService(db);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns issues an agent participated in across the supported signals", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const otherAgentId = 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: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: otherAgentId,
|
||||
companyId,
|
||||
name: "OtherAgent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const assignedIssueId = randomUUID();
|
||||
const createdIssueId = randomUUID();
|
||||
const commentedIssueId = randomUUID();
|
||||
const activityIssueId = randomUUID();
|
||||
const excludedIssueId = randomUUID();
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: assignedIssueId,
|
||||
companyId,
|
||||
title: "Assigned issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
createdByAgentId: otherAgentId,
|
||||
},
|
||||
{
|
||||
id: createdIssueId,
|
||||
companyId,
|
||||
title: "Created issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: commentedIssueId,
|
||||
companyId,
|
||||
title: "Commented issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: otherAgentId,
|
||||
},
|
||||
{
|
||||
id: activityIssueId,
|
||||
companyId,
|
||||
title: "Activity issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: otherAgentId,
|
||||
},
|
||||
{
|
||||
id: excludedIssueId,
|
||||
companyId,
|
||||
title: "Excluded issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: otherAgentId,
|
||||
assigneeAgentId: otherAgentId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: commentedIssueId,
|
||||
authorAgentId: agentId,
|
||||
body: "Investigating this issue.",
|
||||
});
|
||||
|
||||
await db.insert(activityLog).values({
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: agentId,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: activityIssueId,
|
||||
agentId,
|
||||
details: { changed: true },
|
||||
});
|
||||
|
||||
const result = await svc.list(companyId, { participantAgentId: agentId });
|
||||
const resultIds = new Set(result.map((issue) => issue.id));
|
||||
|
||||
expect(resultIds).toEqual(new Set([
|
||||
assignedIssueId,
|
||||
createdIssueId,
|
||||
commentedIssueId,
|
||||
activityIssueId,
|
||||
]));
|
||||
expect(resultIds.has(excludedIssueId)).toBe(false);
|
||||
});
|
||||
|
||||
it("combines participation filtering with search", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = 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: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const matchedIssueId = randomUUID();
|
||||
const otherIssueId = randomUUID();
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: matchedIssueId,
|
||||
companyId,
|
||||
title: "Invoice reconciliation",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: otherIssueId,
|
||||
companyId,
|
||||
title: "Weekly planning",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByAgentId: agentId,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
participantAgentId: agentId,
|
||||
q: "invoice",
|
||||
});
|
||||
|
||||
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveIssueUserContext } from "../services/issues.ts";
|
||||
|
||||
function makeIssue(overrides?: Partial<{
|
||||
createdByUserId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>) {
|
||||
return {
|
||||
createdByUserId: null,
|
||||
assigneeUserId: null,
|
||||
createdAt: new Date("2026-03-06T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-06T11:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveIssueUserContext", () => {
|
||||
it("marks issue unread when external comments are newer than my latest comment", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({ createdByUserId: "user-1" }),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: new Date("2026-03-06T12:00:00.000Z"),
|
||||
myLastReadAt: null,
|
||||
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T12:00:00.000Z");
|
||||
expect(context.lastExternalCommentAt?.toISOString()).toBe("2026-03-06T13:00:00.000Z");
|
||||
expect(context.isUnreadForMe).toBe(true);
|
||||
});
|
||||
|
||||
it("marks issue read when my latest comment is newest", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({ createdByUserId: "user-1" }),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: new Date("2026-03-06T14:00:00.000Z"),
|
||||
myLastReadAt: null,
|
||||
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.isUnreadForMe).toBe(false);
|
||||
});
|
||||
|
||||
it("uses issue creation time as fallback touch point for creator", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: null,
|
||||
myLastReadAt: null,
|
||||
lastExternalCommentAt: new Date("2026-03-06T10:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T09:00:00.000Z");
|
||||
expect(context.isUnreadForMe).toBe(true);
|
||||
});
|
||||
|
||||
it("uses issue updated time as fallback touch point for assignee", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({ assigneeUserId: "user-1", updatedAt: new Date("2026-03-06T15:00:00.000Z") }),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: null,
|
||||
myLastReadAt: null,
|
||||
lastExternalCommentAt: new Date("2026-03-06T14:59:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T15:00:00.000Z");
|
||||
expect(context.isUnreadForMe).toBe(false);
|
||||
});
|
||||
|
||||
it("uses latest read timestamp to clear unread without requiring a comment", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: null,
|
||||
myLastReadAt: new Date("2026-03-06T11:30:00.000Z"),
|
||||
lastExternalCommentAt: new Date("2026-03-06T11:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T11:30:00.000Z");
|
||||
expect(context.isUnreadForMe).toBe(false);
|
||||
});
|
||||
|
||||
it("handles SQL timestamp strings without throwing", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({
|
||||
createdByUserId: "user-1",
|
||||
createdAt: new Date("2026-03-06T09:00:00.000Z"),
|
||||
}),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: "2026-03-06T10:00:00.000Z",
|
||||
myLastReadAt: null,
|
||||
lastExternalCommentAt: "2026-03-06T11:00:00.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T10:00:00.000Z");
|
||||
expect(context.lastExternalCommentAt?.toISOString()).toBe("2026-03-06T11:00:00.000Z");
|
||||
expect(context.isUnreadForMe).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
maskUserNameForLogs,
|
||||
redactCurrentUserText,
|
||||
redactCurrentUserValue,
|
||||
} from "../log-redaction.js";
|
||||
|
||||
describe("log redaction", () => {
|
||||
it("redacts the active username inside home-directory paths", () => {
|
||||
const userName = "paperclipuser";
|
||||
const maskedUserName = maskUserNameForLogs(userName);
|
||||
const input = [
|
||||
`cwd=/Users/${userName}/paperclip`,
|
||||
`home=/home/${userName}/workspace`,
|
||||
`win=C:\\Users\\${userName}\\paperclip`,
|
||||
].join("\n");
|
||||
|
||||
const result = redactCurrentUserText(input, {
|
||||
userNames: [userName],
|
||||
homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`],
|
||||
});
|
||||
|
||||
expect(result).toContain(`cwd=/Users/${maskedUserName}/paperclip`);
|
||||
expect(result).toContain(`home=/home/${maskedUserName}/workspace`);
|
||||
expect(result).toContain(`win=C:\\Users\\${maskedUserName}\\paperclip`);
|
||||
expect(result).not.toContain(userName);
|
||||
});
|
||||
|
||||
it("redacts standalone username mentions without mangling larger tokens", () => {
|
||||
const userName = "paperclipuser";
|
||||
const maskedUserName = maskUserNameForLogs(userName);
|
||||
const result = redactCurrentUserText(
|
||||
`user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`,
|
||||
{
|
||||
userNames: [userName],
|
||||
homeDirs: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
`user ${maskedUserName} said ${maskedUserName}/project should stay but apaperclipuserz should not change`,
|
||||
);
|
||||
});
|
||||
|
||||
it("recursively redacts nested event payloads", () => {
|
||||
const userName = "paperclipuser";
|
||||
const maskedUserName = maskUserNameForLogs(userName);
|
||||
const result = redactCurrentUserValue({
|
||||
cwd: `/Users/${userName}/paperclip`,
|
||||
prompt: `open /Users/${userName}/paperclip/ui`,
|
||||
nested: {
|
||||
author: userName,
|
||||
},
|
||||
values: [userName, `/home/${userName}/project`],
|
||||
}, {
|
||||
userNames: [userName],
|
||||
homeDirs: [`/Users/${userName}`, `/home/${userName}`],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
cwd: `/Users/${maskedUserName}/paperclip`,
|
||||
prompt: `open /Users/${maskedUserName}/paperclip/ui`,
|
||||
nested: {
|
||||
author: maskedUserName,
|
||||
},
|
||||
values: [maskedUserName, `/home/${maskedUserName}/project`],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips redaction when disabled", () => {
|
||||
const input = "cwd=/Users/paperclipuser/paperclip";
|
||||
expect(redactCurrentUserText(input, { enabled: false })).toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { companyService } from "../services/companies.ts";
|
||||
import { agentService } from "../services/agents.ts";
|
||||
|
||||
function createSelectSequenceDb(results: unknown[]) {
|
||||
const pending = [...results];
|
||||
const chain = {
|
||||
from: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
leftJoin: vi.fn(() => chain),
|
||||
groupBy: vi.fn(() => chain),
|
||||
then: vi.fn((resolve: (value: unknown[]) => unknown) => Promise.resolve(resolve(pending.shift() ?? []))),
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(() => chain),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("monthly spend hydration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("recomputes company spentMonthlyCents from the current utc month instead of returning stale stored values", async () => {
|
||||
const dbStub = createSelectSequenceDb([
|
||||
[{
|
||||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
description: null,
|
||||
status: "active",
|
||||
issuePrefix: "PAP",
|
||||
issueCounter: 1,
|
||||
budgetMonthlyCents: 5000,
|
||||
spentMonthlyCents: 999999,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
spentMonthlyCents: 420,
|
||||
}],
|
||||
]);
|
||||
|
||||
const companies = companyService(dbStub.db as any);
|
||||
const [company] = await companies.list();
|
||||
|
||||
expect(company.spentMonthlyCents).toBe(420);
|
||||
});
|
||||
|
||||
it("recomputes agent spentMonthlyCents from the current utc month instead of returning stale stored values", async () => {
|
||||
const dbStub = createSelectSequenceDb([
|
||||
[{
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
role: "general",
|
||||
title: null,
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "claude-local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 5000,
|
||||
spentMonthlyCents: 999999,
|
||||
metadata: null,
|
||||
permissions: null,
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
[{
|
||||
agentId: "agent-1",
|
||||
spentMonthlyCents: 175,
|
||||
}],
|
||||
]);
|
||||
|
||||
const agents = agentService(dbStub.db as any);
|
||||
const agent = await agents.getById("agent-1");
|
||||
|
||||
expect(agent?.spentMonthlyCents).toBe(175);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeAgentMentionToken } from "../services/issues.ts";
|
||||
|
||||
describe("normalizeAgentMentionToken", () => {
|
||||
it("decodes hex numeric entities such as space ( )", () => {
|
||||
expect(normalizeAgentMentionToken("Baba ")).toBe("Baba");
|
||||
});
|
||||
|
||||
it("decodes decimal numeric entities", () => {
|
||||
expect(normalizeAgentMentionToken("Baba ")).toBe("Baba");
|
||||
});
|
||||
|
||||
it("decodes common named whitespace entities", () => {
|
||||
expect(normalizeAgentMentionToken("Baba ")).toBe("Baba");
|
||||
});
|
||||
|
||||
// Mid-token entity (review asked for this shape); we decode &→&, not strip to "Baba" (that broke M&M).
|
||||
it("decodes a named entity in the middle of the token", () => {
|
||||
expect(normalizeAgentMentionToken("Ba&ba")).toBe("Ba&ba");
|
||||
});
|
||||
|
||||
it("decodes & so agent names with ampersands still match", () => {
|
||||
expect(normalizeAgentMentionToken("M&M")).toBe("M&M");
|
||||
});
|
||||
|
||||
it("decodes additional named entities used in rich text (e.g. ©)", () => {
|
||||
expect(normalizeAgentMentionToken("Agent©Name")).toBe("Agent©Name");
|
||||
});
|
||||
|
||||
it("leaves unknown semicolon-terminated named references unchanged", () => {
|
||||
expect(normalizeAgentMentionToken("Baba¬arealentity;")).toBe("Baba¬arealentity;");
|
||||
});
|
||||
|
||||
it("returns plain names unchanged", () => {
|
||||
expect(normalizeAgentMentionToken("Baba")).toBe("Baba");
|
||||
});
|
||||
|
||||
it("trims after decoding entities", () => {
|
||||
expect(normalizeAgentMentionToken("Baba  ")).toBe("Baba");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,625 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
|
||||
import {
|
||||
buildOpenClawGatewayConfig,
|
||||
parseOpenClawGatewayStdoutLine,
|
||||
} from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
|
||||
function buildContext(
|
||||
config: Record<string, unknown>,
|
||||
overrides?: Partial<AdapterExecutionContext>,
|
||||
): AdapterExecutionContext {
|
||||
return {
|
||||
runId: "run-123",
|
||||
agent: {
|
||||
id: "agent-123",
|
||||
companyId: "company-123",
|
||||
name: "OpenClaw Gateway Agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config,
|
||||
context: {
|
||||
taskId: "task-123",
|
||||
issueId: "issue-123",
|
||||
wakeReason: "issue_assigned",
|
||||
issueIds: ["issue-123"],
|
||||
},
|
||||
onLog: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function createMockGatewayServer(options?: {
|
||||
waitPayload?: Record<string, unknown>;
|
||||
}) {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
let agentPayload: Record<string, unknown> | null = null;
|
||||
|
||||
wss.on("connection", (socket) => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "nonce-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
socket.on("message", (raw) => {
|
||||
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
||||
const frame = JSON.parse(text) as {
|
||||
type: string;
|
||||
id: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (frame.type !== "req") return;
|
||||
|
||||
if (frame.method === "connect") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 3,
|
||||
server: { version: "test", connId: "conn-1" },
|
||||
features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] },
|
||||
snapshot: { version: 1, ts: Date.now() },
|
||||
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent") {
|
||||
agentPayload = frame.params ?? null;
|
||||
const runId =
|
||||
typeof frame.params?.idempotencyKey === "string"
|
||||
? frame.params.idempotencyKey
|
||||
: "run-123";
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId,
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { delta: "cha" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId,
|
||||
seq: 2,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { delta: "chacha" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent.wait") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: options?.waitPayload ?? {
|
||||
runId: frame.params?.runId,
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to resolve test server address");
|
||||
}
|
||||
|
||||
return {
|
||||
url: `ws://127.0.0.1:${address.port}`,
|
||||
getAgentPayload: () => agentPayload,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createMockGatewayServerWithPairing() {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
let agentPayload: Record<string, unknown> | null = null;
|
||||
let approved = false;
|
||||
let pendingRequestId = "req-1";
|
||||
let lastSeenDeviceId: string | null = null;
|
||||
|
||||
wss.on("connection", (socket) => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "nonce-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
socket.on("message", (raw) => {
|
||||
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
||||
const frame = JSON.parse(text) as {
|
||||
type: string;
|
||||
id: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (frame.type !== "req") return;
|
||||
|
||||
if (frame.method === "connect") {
|
||||
const device = frame.params?.device as Record<string, unknown> | undefined;
|
||||
const deviceId = typeof device?.id === "string" ? device.id : null;
|
||||
if (deviceId) {
|
||||
lastSeenDeviceId = deviceId;
|
||||
}
|
||||
|
||||
if (deviceId && !approved) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NOT_PAIRED",
|
||||
message: "pairing required",
|
||||
details: {
|
||||
code: "PAIRING_REQUIRED",
|
||||
requestId: pendingRequestId,
|
||||
reason: "not-paired",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
socket.close(1008, "pairing required");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 3,
|
||||
server: { version: "test", connId: "conn-1" },
|
||||
features: {
|
||||
methods: ["connect", "agent", "agent.wait", "device.pair.list", "device.pair.approve"],
|
||||
events: ["agent"],
|
||||
},
|
||||
snapshot: { version: 1, ts: Date.now() },
|
||||
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "device.pair.list") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
pending: approved
|
||||
? []
|
||||
: [
|
||||
{
|
||||
requestId: pendingRequestId,
|
||||
deviceId: lastSeenDeviceId ?? "device-unknown",
|
||||
},
|
||||
],
|
||||
paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "device.pair.approve") {
|
||||
const requestId = frame.params?.requestId;
|
||||
if (requestId !== pendingRequestId) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: "unknown requestId" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
approved = true;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
requestId: pendingRequestId,
|
||||
device: {
|
||||
deviceId: lastSeenDeviceId ?? "device-unknown",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent") {
|
||||
agentPayload = frame.params ?? null;
|
||||
const runId =
|
||||
typeof frame.params?.idempotencyKey === "string"
|
||||
? frame.params.idempotencyKey
|
||||
: "run-123";
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId,
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { delta: "ok" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent.wait") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId: frame.params?.runId,
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to resolve test server address");
|
||||
}
|
||||
|
||||
return {
|
||||
url: `ws://127.0.0.1:${address.port}`,
|
||||
getAgentPayload: () => agentPayload,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// no global mocks
|
||||
});
|
||||
|
||||
describe("openclaw gateway ui stdout parser", () => {
|
||||
it("parses assistant deltas from gateway event lines", () => {
|
||||
const ts = "2026-03-06T15:00:00.000Z";
|
||||
const line =
|
||||
'[openclaw-gateway:event] run=run-1 stream=assistant data={"delta":"hello"}';
|
||||
|
||||
expect(parseOpenClawGatewayStdoutLine(line, ts)).toEqual([
|
||||
{
|
||||
kind: "assistant",
|
||||
ts,
|
||||
text: "hello",
|
||||
delta: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway adapter execute", () => {
|
||||
it("runs connect -> agent -> agent.wait and forwards wake payload", async () => {
|
||||
const gateway = await createMockGatewayServer();
|
||||
const logs: string[] = [];
|
||||
|
||||
try {
|
||||
const result = await execute(
|
||||
buildContext(
|
||||
{
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2000,
|
||||
},
|
||||
{
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
context: {
|
||||
taskId: "task-123",
|
||||
issueId: "issue-123",
|
||||
wakeReason: "issue_assigned",
|
||||
issueIds: ["issue-123"],
|
||||
paperclipWorkspace: {
|
||||
cwd: "/tmp/worktrees/pap-123",
|
||||
strategy: "git_worktree",
|
||||
branchName: "pap-123-test",
|
||||
},
|
||||
paperclipWorkspaces: [
|
||||
{
|
||||
id: "workspace-1",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
],
|
||||
paperclipRuntimeServiceIntents: [
|
||||
{
|
||||
name: "preview",
|
||||
lifecycle: "ephemeral",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.summary).toContain("chachacha");
|
||||
expect(result.provider).toBe("openclaw");
|
||||
|
||||
const payload = gateway.getAgentPayload();
|
||||
expect(payload).toBeTruthy();
|
||||
expect(payload?.idempotencyKey).toBe("run-123");
|
||||
expect(payload?.sessionKey).toBe("paperclip:issue:issue-123");
|
||||
expect(String(payload?.message ?? "")).toContain("wake now");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
|
||||
|
||||
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
|
||||
} finally {
|
||||
await gateway.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails fast when url is missing", async () => {
|
||||
const result = await execute(buildContext({}));
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
||||
});
|
||||
|
||||
it("returns adapter-managed runtime services from gateway result meta", async () => {
|
||||
const gateway = await createMockGatewayServer({
|
||||
waitPayload: {
|
||||
runId: "run-123",
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
meta: {
|
||||
runtimeServices: [
|
||||
{
|
||||
name: "preview",
|
||||
scopeType: "run",
|
||||
url: "https://preview.example/run-123",
|
||||
providerRef: "sandbox-123",
|
||||
lifecycle: "ephemeral",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
waitTimeoutMs: 2000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.runtimeServices).toEqual([
|
||||
expect.objectContaining({
|
||||
serviceName: "preview",
|
||||
scopeType: "run",
|
||||
url: "https://preview.example/run-123",
|
||||
providerRef: "sandbox-123",
|
||||
lifecycle: "ephemeral",
|
||||
status: "running",
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await gateway.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-approves pairing once and retries the run", async () => {
|
||||
const gateway = await createMockGatewayServerWithPairing();
|
||||
const logs: string[] = [];
|
||||
|
||||
try {
|
||||
const result = await execute(
|
||||
buildContext(
|
||||
{
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2000,
|
||||
},
|
||||
{
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.summary).toContain("ok");
|
||||
expect(logs.some((entry) => entry.includes("pairing required; attempting automatic pairing approval"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(logs.some((entry) => entry.includes("auto-approved pairing request"))).toBe(true);
|
||||
expect(gateway.getAgentPayload()).toBeTruthy();
|
||||
} finally {
|
||||
await gateway.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway ui build config", () => {
|
||||
it("parses payload template and runtime services json", () => {
|
||||
const config = buildOpenClawGatewayConfig({
|
||||
adapterType: "openclaw_gateway",
|
||||
cwd: "",
|
||||
promptTemplate: "",
|
||||
model: "",
|
||||
thinkingEffort: "",
|
||||
chrome: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
search: false,
|
||||
dangerouslyBypassSandbox: false,
|
||||
command: "",
|
||||
args: "",
|
||||
extraArgs: "",
|
||||
envVars: "",
|
||||
envBindings: {},
|
||||
url: "wss://gateway.example/ws",
|
||||
payloadTemplateJson: JSON.stringify({
|
||||
agentId: "remote-agent-123",
|
||||
metadata: { team: "platform" },
|
||||
}),
|
||||
runtimeServicesJson: JSON.stringify({
|
||||
services: [
|
||||
{
|
||||
name: "preview",
|
||||
lifecycle: "shared",
|
||||
},
|
||||
],
|
||||
}),
|
||||
bootstrapPrompt: "",
|
||||
maxTurnsPerRun: 0,
|
||||
heartbeatEnabled: true,
|
||||
intervalSec: 300,
|
||||
});
|
||||
|
||||
expect(config).toEqual(
|
||||
expect.objectContaining({
|
||||
url: "wss://gateway.example/ws",
|
||||
payloadTemplate: {
|
||||
agentId: "remote-agent-123",
|
||||
metadata: { team: "platform" },
|
||||
},
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "preview",
|
||||
lifecycle: "shared",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway testEnvironment", () => {
|
||||
it("reports missing url as failure", async () => {
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-123",
|
||||
adapterType: "openclaw_gateway",
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks.some((check) => check.code === "openclaw_gateway_url_missing")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
hasPermission: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
isInstanceAdmin: vi.fn(),
|
||||
getMembership: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
listMembers: vi.fn(),
|
||||
setMemberPermissions: vi.fn(),
|
||||
promoteInstanceAdmin: vi.fn(),
|
||||
demoteInstanceAdmin: vi.fn(),
|
||||
listUserCompanyAccess: vi.fn(),
|
||||
setUserCompanyAccess: vi.fn(),
|
||||
setPrincipalGrants: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBoardAuthService = vi.hoisted(() => ({
|
||||
createCliAuthChallenge: vi.fn(),
|
||||
describeCliAuthChallenge: vi.fn(),
|
||||
approveCliAuthChallenge: vi.fn(),
|
||||
cancelCliAuthChallenge: vi.fn(),
|
||||
resolveBoardAccess: vi.fn(),
|
||||
assertCurrentBoardKey: vi.fn(),
|
||||
revokeBoardApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
const createdInvite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "agent",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2026-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
tokenHash: "hash",
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
const returning = vi.fn().mockResolvedValue([createdInvite]);
|
||||
const values = vi.fn().mockReturnValue({ returning });
|
||||
const insert = vi.fn().mockReturnValue({ values });
|
||||
return {
|
||||
insert,
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes(db as any, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
beforeEach(() => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockReset();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("rejects non-CEO agent callers", async () => {
|
||||
const db = createDbStub();
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "engineer",
|
||||
});
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Only CEO agents");
|
||||
});
|
||||
|
||||
it("allows CEO agent callers and creates an agent-only invite", async () => {
|
||||
const db = createDbStub();
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "ceo",
|
||||
});
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({ agentMessage: "Join and configure OpenClaw gateway." });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
expect(typeof res.body.token).toBe("string");
|
||||
expect(res.body.onboardingTextPath).toContain("/api/invites/");
|
||||
});
|
||||
|
||||
it("allows board callers with invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
});
|
||||
|
||||
it("rejects board callers without invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Permission denied");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { testEnvironment } from "@paperclipai/adapter-opencode-local/server";
|
||||
|
||||
describe("opencode_local environment diagnostics", () => {
|
||||
it("reports a missing working directory as an error when cwd is absolute", async () => {
|
||||
const cwd = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-opencode-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
"workspace",
|
||||
);
|
||||
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "opencode_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "opencode_cwd_invalid")).toBe(true);
|
||||
expect(result.checks.some((check) => check.level === "error")).toBe(true);
|
||||
expect(result.status).toBe("fail");
|
||||
});
|
||||
|
||||
it("treats an empty OPENAI_API_KEY override as missing", async () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-env-empty-key-"));
|
||||
const originalOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
process.env.OPENAI_API_KEY = "sk-host-value";
|
||||
|
||||
try {
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "opencode_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
env: {
|
||||
OPENAI_API_KEY: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const missingCheck = result.checks.find((check) => check.code === "opencode_openai_api_key_missing");
|
||||
expect(missingCheck).toBeTruthy();
|
||||
expect(missingCheck?.hint).toContain("empty");
|
||||
} finally {
|
||||
if (originalOpenAiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = originalOpenAiKey;
|
||||
}
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies ProviderModelNotFoundError probe output as model-unavailable warning", async () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-env-probe-cwd-"));
|
||||
const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-env-probe-bin-"));
|
||||
const fakeOpencode = path.join(binDir, "opencode");
|
||||
const script = [
|
||||
"#!/bin/sh",
|
||||
"echo 'ProviderModelNotFoundError: ProviderModelNotFoundError' 1>&2",
|
||||
"echo 'data: { providerID: \"openai\", modelID: \"gpt-5.3-codex\", suggestions: [] }' 1>&2",
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
await fs.writeFile(fakeOpencode, script, "utf8");
|
||||
await fs.chmod(fakeOpencode, 0o755);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "opencode_local",
|
||||
config: {
|
||||
command: fakeOpencode,
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
|
||||
const modelCheck = result.checks.find((check) => check.code === "opencode_hello_probe_model_unavailable");
|
||||
expect(modelCheck).toBeTruthy();
|
||||
expect(modelCheck?.level).toBe("warn");
|
||||
expect(result.status).toBe("warn");
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
await fs.rm(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { parseOpenCodeStdoutLine } from "@paperclipai/adapter-opencode-local/ui";
|
||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||
|
||||
describe("opencode_local parser", () => {
|
||||
it("extracts session, summary, usage, cost, and terminal error message", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ type: "step_start", sessionID: "ses_123" }),
|
||||
JSON.stringify({ type: "text", part: { type: "text", text: "hello" } }),
|
||||
JSON.stringify({
|
||||
type: "step_finish",
|
||||
part: {
|
||||
reason: "tool-calls",
|
||||
cost: 0.001,
|
||||
tokens: {
|
||||
input: 100,
|
||||
output: 40,
|
||||
cache: { read: 20, write: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "step_finish",
|
||||
part: {
|
||||
reason: "stop",
|
||||
cost: 0.002,
|
||||
tokens: {
|
||||
input: 50,
|
||||
output: 25,
|
||||
cache: { read: 10, write: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({ type: "error", message: "model access denied" }),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseOpenCodeJsonl(stdout);
|
||||
expect(parsed.sessionId).toBe("ses_123");
|
||||
expect(parsed.summary).toBe("hello");
|
||||
expect(parsed.usage).toEqual({
|
||||
inputTokens: 150,
|
||||
cachedInputTokens: 30,
|
||||
outputTokens: 65,
|
||||
});
|
||||
expect(parsed.costUsd).toBeCloseTo(0.003, 6);
|
||||
expect(parsed.errorMessage).toBe("model access denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("opencode_local stale session detection", () => {
|
||||
it("treats missing persisted session file as an unknown session error", () => {
|
||||
const stderr =
|
||||
"NotFoundError: Resource not found: /Users/test/.local/share/opencode/storage/session/project/ses_missing.json";
|
||||
|
||||
expect(isOpenCodeUnknownSessionError("", stderr)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("opencode_local ui stdout parser", () => {
|
||||
it("parses assistant and tool lifecycle events", () => {
|
||||
const ts = "2026-03-04T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseOpenCodeStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "text",
|
||||
part: {
|
||||
type: "text",
|
||||
text: "I will run a command.",
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "assistant",
|
||||
ts,
|
||||
text: "I will run a command.",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseOpenCodeStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "tool_use",
|
||||
part: {
|
||||
id: "prt_tool_1",
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "ls -1" },
|
||||
output: "AGENTS.md\nDockerfile\n",
|
||||
metadata: { exit: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "bash",
|
||||
toolUseId: "call_1",
|
||||
input: { command: "ls -1" },
|
||||
},
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: "call_1",
|
||||
content: "status: completed\nexit: 0\n\nAGENTS.md\nDockerfile",
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses finished steps into usage-aware results", () => {
|
||||
const ts = "2026-03-04T00:00:00.000Z";
|
||||
expect(
|
||||
parseOpenCodeStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "step_finish",
|
||||
part: {
|
||||
reason: "stop",
|
||||
cost: 0.00042,
|
||||
tokens: {
|
||||
input: 10,
|
||||
output: 5,
|
||||
cache: { read: 2, write: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "stop",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cachedTokens: 2,
|
||||
costUsd: 0.00042,
|
||||
subtype: "stop",
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
describe("opencode_local cli formatter", () => {
|
||||
it("prints step, assistant, tool, and result events", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
printOpenCodeStreamEvent(
|
||||
JSON.stringify({ type: "step_start", sessionID: "ses_abc" }),
|
||||
false,
|
||||
);
|
||||
printOpenCodeStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "text",
|
||||
part: { type: "text", text: "hello" },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printOpenCodeStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "tool_use",
|
||||
part: {
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "ls -1" },
|
||||
output: "AGENTS.md\n",
|
||||
metadata: { exit: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printOpenCodeStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "step_finish",
|
||||
part: {
|
||||
reason: "stop",
|
||||
cost: 0.00042,
|
||||
tokens: {
|
||||
input: 10,
|
||||
output: 5,
|
||||
cache: { read: 2, write: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
const lines = spy.mock.calls
|
||||
.map((call) => call.map((v) => String(v)).join(" "))
|
||||
.map(stripAnsi);
|
||||
|
||||
expect(lines).toEqual(
|
||||
expect.arrayContaining([
|
||||
"step started (session: ses_abc)",
|
||||
"assistant: hello",
|
||||
"tool_call: bash (call_1)",
|
||||
"tool_result status=completed exit=0",
|
||||
"AGENTS.md",
|
||||
"step finished: reason=stop",
|
||||
"tokens: in=10 out=5 cached=2 cost=$0.000420",
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listOpenCodeSkills,
|
||||
syncOpenCodeSkills,
|
||||
} from "@paperclipai/adapter-opencode-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("opencode local skill sync", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the shared Claude/OpenCode skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-opencode-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "opencode_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listOpenCodeSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills).");
|
||||
expect(before.desiredSkills).toContain(paperclipKey);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||
|
||||
const after = await syncOpenCodeSkills(ctx, [paperclipKey]);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-opencode-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "opencode_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncOpenCodeSkills(configuredCtx, [paperclipKey]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncOpenCodeSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toContain(paperclipKey);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildPaperclipEnv } from "../adapters/utils.js";
|
||||
|
||||
const ORIGINAL_PAPERCLIP_API_URL = process.env.PAPERCLIP_API_URL;
|
||||
const ORIGINAL_PAPERCLIP_LISTEN_HOST = process.env.PAPERCLIP_LISTEN_HOST;
|
||||
const ORIGINAL_PAPERCLIP_LISTEN_PORT = process.env.PAPERCLIP_LISTEN_PORT;
|
||||
const ORIGINAL_HOST = process.env.HOST;
|
||||
const ORIGINAL_PORT = process.env.PORT;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_PAPERCLIP_API_URL === undefined) delete process.env.PAPERCLIP_API_URL;
|
||||
else process.env.PAPERCLIP_API_URL = ORIGINAL_PAPERCLIP_API_URL;
|
||||
|
||||
if (ORIGINAL_PAPERCLIP_LISTEN_HOST === undefined) delete process.env.PAPERCLIP_LISTEN_HOST;
|
||||
else process.env.PAPERCLIP_LISTEN_HOST = ORIGINAL_PAPERCLIP_LISTEN_HOST;
|
||||
|
||||
if (ORIGINAL_PAPERCLIP_LISTEN_PORT === undefined) delete process.env.PAPERCLIP_LISTEN_PORT;
|
||||
else process.env.PAPERCLIP_LISTEN_PORT = ORIGINAL_PAPERCLIP_LISTEN_PORT;
|
||||
|
||||
if (ORIGINAL_HOST === undefined) delete process.env.HOST;
|
||||
else process.env.HOST = ORIGINAL_HOST;
|
||||
|
||||
if (ORIGINAL_PORT === undefined) delete process.env.PORT;
|
||||
else process.env.PORT = ORIGINAL_PORT;
|
||||
});
|
||||
|
||||
describe("buildPaperclipEnv", () => {
|
||||
it("prefers an explicit PAPERCLIP_API_URL", () => {
|
||||
process.env.PAPERCLIP_API_URL = "http://localhost:4100";
|
||||
process.env.PAPERCLIP_LISTEN_HOST = "127.0.0.1";
|
||||
process.env.PAPERCLIP_LISTEN_PORT = "3101";
|
||||
|
||||
const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" });
|
||||
|
||||
expect(env.PAPERCLIP_API_URL).toBe("http://localhost:4100");
|
||||
});
|
||||
|
||||
it("uses runtime listen host/port when explicit URL is not set", () => {
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
process.env.PAPERCLIP_LISTEN_HOST = "0.0.0.0";
|
||||
process.env.PAPERCLIP_LISTEN_PORT = "3101";
|
||||
process.env.PORT = "3100";
|
||||
|
||||
const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" });
|
||||
|
||||
expect(env.PAPERCLIP_API_URL).toBe("http://localhost:3101");
|
||||
});
|
||||
|
||||
it("formats IPv6 hosts safely in fallback URL generation", () => {
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
process.env.PAPERCLIP_LISTEN_HOST = "::1";
|
||||
process.env.PAPERCLIP_LISTEN_PORT = "3101";
|
||||
|
||||
const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" });
|
||||
|
||||
expect(env.PAPERCLIP_API_URL).toBe("http://[::1]:3101");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listPaperclipSkillEntries,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("paperclip skill utils", () => {
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("lists runtime skills from ./skills without pulling in .agents/skills", async () => {
|
||||
const root = await makeTempDir("paperclip-skill-roots-");
|
||||
cleanupDirs.add(root);
|
||||
|
||||
const moduleDir = path.join(root, "a", "b", "c", "d", "e");
|
||||
await fs.mkdir(moduleDir, { recursive: true });
|
||||
await fs.mkdir(path.join(root, "skills", "paperclip"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, ".agents", "skills", "release"), { recursive: true });
|
||||
|
||||
const entries = await listPaperclipSkillEntries(moduleDir);
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual(["paperclipai/paperclip/paperclip"]);
|
||||
expect(entries.map((entry) => entry.runtimeName)).toEqual(["paperclip"]);
|
||||
expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip"));
|
||||
});
|
||||
|
||||
it("removes stale maintainer-only symlinks from a shared skills home", async () => {
|
||||
const root = await makeTempDir("paperclip-skill-cleanup-");
|
||||
cleanupDirs.add(root);
|
||||
|
||||
const skillsHome = path.join(root, "skills-home");
|
||||
const runtimeSkill = path.join(root, "skills", "paperclip");
|
||||
const customSkill = path.join(root, "custom", "release-notes");
|
||||
const staleMaintainerSkill = path.join(root, ".agents", "skills", "release");
|
||||
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
await fs.mkdir(runtimeSkill, { recursive: true });
|
||||
await fs.mkdir(customSkill, { recursive: true });
|
||||
|
||||
await fs.symlink(runtimeSkill, path.join(skillsHome, "paperclip"));
|
||||
await fs.symlink(customSkill, path.join(skillsHome, "release-notes"));
|
||||
await fs.symlink(staleMaintainerSkill, path.join(skillsHome, "release"));
|
||||
|
||||
const removed = await removeMaintainerOnlySkillSymlinks(skillsHome, ["paperclip"]);
|
||||
|
||||
expect(removed).toEqual(["release"]);
|
||||
await expect(fs.lstat(path.join(skillsHome, "release"))).rejects.toThrow();
|
||||
expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
expect((await fs.lstat(path.join(skillsHome, "release-notes"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { testEnvironment } from "@paperclipai/adapter-pi-local/server";
|
||||
|
||||
async function writeFakePiCommand(binDir: string, mode: "success" | "stale-package"): Promise<void> {
|
||||
const commandPath = path.join(binDir, "pi");
|
||||
const script =
|
||||
mode === "success"
|
||||
? `#!/usr/bin/env node
|
||||
if (process.argv.includes("--list-models")) {
|
||||
console.log("provider model");
|
||||
console.log("openai gpt-4.1-mini");
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(JSON.stringify({ type: "session", version: 3, id: "session-1", timestamp: new Date().toISOString(), cwd: process.cwd() }));
|
||||
console.log(JSON.stringify({ type: "agent_start" }));
|
||||
console.log(JSON.stringify({ type: "turn_start" }));
|
||||
console.log(JSON.stringify({
|
||||
type: "turn_end",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
usage: { input: 1, output: 1, cacheRead: 0, cost: { total: 0 } }
|
||||
},
|
||||
toolResults: []
|
||||
}));
|
||||
`
|
||||
: `#!/usr/bin/env node
|
||||
if (process.argv.includes("--list-models")) {
|
||||
console.error("npm error 404 'pi-driver@*' is not in this registry.");
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(1);
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
describe("pi_local environment diagnostics", () => {
|
||||
it("passes a hello probe when model discovery and execution succeed", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-pi-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const binDir = path.join(root, "bin");
|
||||
const cwd = path.join(root, "workspace");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
await writeFakePiCommand(binDir, "success");
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "pi_local",
|
||||
config: {
|
||||
command: "pi",
|
||||
cwd,
|
||||
model: "openai/gpt-4.1-mini",
|
||||
env: {
|
||||
OPENAI_API_KEY: "test-key",
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
expect(result.checks.some((check) => check.code === "pi_models_discovered")).toBe(true);
|
||||
expect(result.checks.some((check) => check.code === "pi_hello_probe_passed")).toBe(true);
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("surfaces stale configured package installs with a targeted hint", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-pi-local-stale-package-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const binDir = path.join(root, "bin");
|
||||
const cwd = path.join(root, "workspace");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
await writeFakePiCommand(binDir, "stale-package");
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "pi_local",
|
||||
config: {
|
||||
command: "pi",
|
||||
cwd,
|
||||
env: {
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const stalePackageCheck = result.checks.find((check) => check.code === "pi_package_install_failed");
|
||||
expect(stalePackageCheck?.level).toBe("warn");
|
||||
expect(stalePackageCheck?.hint).toContain("Remove `npm:pi-driver`");
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listPiSkills,
|
||||
syncPiSkills,
|
||||
} from "@paperclipai/adapter-pi-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("pi local skill sync", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the Pi skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-pi-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "pi_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listPiSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toContain(paperclipKey);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||
|
||||
const after = await syncPiSkills(ctx, [paperclipKey]);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-pi-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "pi_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncPiSkills(configuredCtx, [paperclipKey]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncPiSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toContain(paperclipKey);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolvePluginWatchTargets } from "../services/plugin-dev-watcher.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempPluginDir(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-plugin-watch-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("resolvePluginWatchTargets", () => {
|
||||
it("watches package metadata plus concrete declared runtime files", () => {
|
||||
const pluginDir = makeTempPluginDir();
|
||||
mkdirSync(path.join(pluginDir, "dist", "ui"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@acme/example",
|
||||
paperclipPlugin: {
|
||||
manifest: "./dist/manifest.js",
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "worker.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "ui", "index.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "ui", "index.css"), "body {}\n");
|
||||
|
||||
const targets = resolvePluginWatchTargets(pluginDir);
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ path: path.join(pluginDir, "dist", "manifest.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "ui", "index.css"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "ui", "index.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "worker.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "package.json"), recursive: false, kind: "file" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to dist when package metadata does not declare entrypoints", () => {
|
||||
const pluginDir = makeTempPluginDir();
|
||||
mkdirSync(path.join(pluginDir, "dist", "nested"), { recursive: true });
|
||||
writeFileSync(path.join(pluginDir, "package.json"), JSON.stringify({ name: "@acme/example" }));
|
||||
writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "nested", "chunk.js"), "export default {};\n");
|
||||
|
||||
const targets = resolvePluginWatchTargets(pluginDir);
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ path: path.join(pluginDir, "package.json"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "manifest.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "nested", "chunk.js"), recursive: false, kind: "file" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendStderrExcerpt, formatWorkerFailureMessage } from "../services/plugin-worker-manager.js";
|
||||
|
||||
describe("plugin-worker-manager stderr failure context", () => {
|
||||
it("appends worker stderr context to failure messages", () => {
|
||||
expect(
|
||||
formatWorkerFailureMessage(
|
||||
"Worker process exited (code=1, signal=null)",
|
||||
"TypeError: Unknown file extension \".ts\"",
|
||||
),
|
||||
).toBe(
|
||||
"Worker process exited (code=1, signal=null)\n\nWorker stderr:\nTypeError: Unknown file extension \".ts\"",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate stderr that is already present", () => {
|
||||
const message = [
|
||||
"Worker process exited (code=1, signal=null)",
|
||||
"",
|
||||
"Worker stderr:",
|
||||
"TypeError: Unknown file extension \".ts\"",
|
||||
].join("\n");
|
||||
|
||||
expect(
|
||||
formatWorkerFailureMessage(message, "TypeError: Unknown file extension \".ts\""),
|
||||
).toBe(message);
|
||||
});
|
||||
|
||||
it("keeps only the latest stderr excerpt", () => {
|
||||
let excerpt = "";
|
||||
excerpt = appendStderrExcerpt(excerpt, "first line");
|
||||
excerpt = appendStderrExcerpt(excerpt, "second line");
|
||||
|
||||
expect(excerpt).toContain("first line");
|
||||
expect(excerpt).toContain("second line");
|
||||
|
||||
excerpt = appendStderrExcerpt(excerpt, "x".repeat(9_000));
|
||||
|
||||
expect(excerpt).not.toContain("first line");
|
||||
expect(excerpt).not.toContain("second line");
|
||||
expect(excerpt.length).toBeLessThanOrEqual(8_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
||||
|
||||
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
|
||||
const app = express();
|
||||
app.use(
|
||||
privateHostnameGuard({
|
||||
enabled: opts.enabled,
|
||||
allowedHostnames: opts.allowedHostnames ?? [],
|
||||
bindHost: opts.bindHost ?? "0.0.0.0",
|
||||
}),
|
||||
);
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.status(200).json({ status: "ok" });
|
||||
});
|
||||
app.get("/dashboard", (_req, res) => {
|
||||
res.status(200).send("ok");
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("privateHostnameGuard", () => {
|
||||
it("allows requests when disabled", async () => {
|
||||
const app = createApp({ enabled: false });
|
||||
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows loopback hostnames", async () => {
|
||||
const app = createApp({ enabled: true });
|
||||
const res = await request(app).get("/api/health").set("Host", "localhost:3100");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows explicitly configured hostnames", async () => {
|
||||
const app = createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
|
||||
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("blocks unknown hostnames with remediation command", async () => {
|
||||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.error).toContain("please run pnpm paperclipai allowed-hostname dotta-macbook-pro");
|
||||
});
|
||||
|
||||
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
|
||||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const res = await request(app).get("/dashboard").set("Host", "dotta-macbook-pro:3100");
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.text).toContain("please run pnpm paperclipai allowed-hostname dotta-macbook-pro");
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProjectNameForUniqueShortname } from "../services/projects.ts";
|
||||
|
||||
describe("resolveProjectNameForUniqueShortname", () => {
|
||||
it("keeps name when shortname is not used", () => {
|
||||
const resolved = resolveProjectNameForUniqueShortname("Platform", [
|
||||
{ id: "p1", name: "Growth" },
|
||||
]);
|
||||
expect(resolved).toBe("Platform");
|
||||
});
|
||||
|
||||
it("appends numeric suffix when shortname collides", () => {
|
||||
const resolved = resolveProjectNameForUniqueShortname("Growth Team", [
|
||||
{ id: "p1", name: "growth-team" },
|
||||
]);
|
||||
expect(resolved).toBe("Growth Team 2");
|
||||
});
|
||||
|
||||
it("increments suffix until unique", () => {
|
||||
const resolved = resolveProjectNameForUniqueShortname("Growth Team", [
|
||||
{ id: "p1", name: "growth-team" },
|
||||
{ id: "p2", name: "growth-team-2" },
|
||||
]);
|
||||
expect(resolved).toBe("Growth Team 3");
|
||||
});
|
||||
|
||||
it("ignores excluded project id", () => {
|
||||
const resolved = resolveProjectNameForUniqueShortname(
|
||||
"Growth Team",
|
||||
[
|
||||
{ id: "p1", name: "growth-team" },
|
||||
{ id: "p2", name: "platform" },
|
||||
],
|
||||
{ excludeProjectId: "p1" },
|
||||
);
|
||||
expect(resolved).toBe("Growth Team");
|
||||
});
|
||||
|
||||
it("keeps non-normalizable names unchanged", () => {
|
||||
const resolved = resolveProjectNameForUniqueShortname("!!!", [
|
||||
{ id: "p1", name: "growth" },
|
||||
]);
|
||||
expect(resolved).toBe("!!!");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../adapters/registry.js", () => ({
|
||||
listServerAdapters: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listServerAdapters } from "../adapters/registry.js";
|
||||
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
||||
|
||||
describe("fetchAllQuotaWindows", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns adapter results without waiting for a slower provider to finish forever", async () => {
|
||||
vi.mocked(listServerAdapters).mockReturnValue([
|
||||
{
|
||||
type: "codex_local",
|
||||
getQuotaWindows: vi.fn().mockResolvedValue({
|
||||
provider: "openai",
|
||||
source: "codex-rpc",
|
||||
ok: true,
|
||||
windows: [{ label: "5h limit", usedPercent: 2, resetsAt: null, valueLabel: null, detail: null }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "claude_local",
|
||||
getQuotaWindows: vi.fn(() => new Promise(() => {})),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const promise = fetchAllQuotaWindows();
|
||||
await vi.advanceTimersByTimeAsync(20_001);
|
||||
const results = await promise;
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
provider: "openai",
|
||||
source: "codex-rpc",
|
||||
ok: true,
|
||||
windows: [{ label: "5h limit", usedPercent: 2, resetsAt: null, valueLabel: null, detail: null }],
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
ok: false,
|
||||
error: "quota polling timed out after 20s",
|
||||
windows: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,812 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { QuotaWindow } from "@paperclipai/adapter-utils";
|
||||
|
||||
// Pure utility functions — import directly from adapter source
|
||||
import {
|
||||
toPercent,
|
||||
fetchWithTimeout,
|
||||
fetchClaudeQuota,
|
||||
parseClaudeCliUsageText,
|
||||
readClaudeToken,
|
||||
claudeConfigDir,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
import {
|
||||
secondsToWindowLabel,
|
||||
readCodexAuthInfo,
|
||||
readCodexToken,
|
||||
fetchCodexQuota,
|
||||
mapCodexRpcQuota,
|
||||
codexHomeDir,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toPercent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("toPercent", () => {
|
||||
it("returns null for null input", () => {
|
||||
expect(toPercent(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for undefined input", () => {
|
||||
expect(toPercent(undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it("converts 0 to 0", () => {
|
||||
expect(toPercent(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("converts 0.5 to 50", () => {
|
||||
expect(toPercent(0.5)).toBe(50);
|
||||
});
|
||||
|
||||
it("converts 1.0 to 100", () => {
|
||||
expect(toPercent(1.0)).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps overshoot to 100", () => {
|
||||
// floating-point utilization can slightly exceed 1.0
|
||||
expect(toPercent(1.001)).toBe(100);
|
||||
expect(toPercent(1.01)).toBe(100);
|
||||
});
|
||||
|
||||
it("rounds to nearest integer", () => {
|
||||
expect(toPercent(0.333)).toBe(33);
|
||||
expect(toPercent(0.666)).toBe(67);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// secondsToWindowLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("secondsToWindowLabel", () => {
|
||||
it("returns fallback for null seconds", () => {
|
||||
expect(secondsToWindowLabel(null, "Primary")).toBe("Primary");
|
||||
});
|
||||
|
||||
it("returns fallback for undefined seconds", () => {
|
||||
expect(secondsToWindowLabel(undefined, "Secondary")).toBe("Secondary");
|
||||
});
|
||||
|
||||
it("labels windows under 6 hours as '5h'", () => {
|
||||
expect(secondsToWindowLabel(3600, "fallback")).toBe("5h"); // 1h
|
||||
expect(secondsToWindowLabel(18000, "fallback")).toBe("5h"); // 5h exactly
|
||||
});
|
||||
|
||||
it("labels windows up to 24 hours as '24h'", () => {
|
||||
expect(secondsToWindowLabel(21600, "fallback")).toBe("24h"); // 6h (≥6h boundary)
|
||||
expect(secondsToWindowLabel(86400, "fallback")).toBe("24h"); // 24h exactly
|
||||
});
|
||||
|
||||
it("labels windows up to 7 days as '7d'", () => {
|
||||
expect(secondsToWindowLabel(86401, "fallback")).toBe("7d"); // just over 24h
|
||||
expect(secondsToWindowLabel(604800, "fallback")).toBe("7d"); // 7d exactly
|
||||
});
|
||||
|
||||
it("labels windows beyond 7 days with actual day count", () => {
|
||||
expect(secondsToWindowLabel(1209600, "fallback")).toBe("14d"); // 14d
|
||||
expect(secondsToWindowLabel(2592000, "fallback")).toBe("30d"); // 30d
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WHAM used_percent normalization (codex / openai)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("WHAM used_percent normalization via fetchCodexQuota", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetch(body: unknown) {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => body,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
it("treats values >= 1 as already-percentage (50 → 50%)", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 50,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(50);
|
||||
});
|
||||
|
||||
it("treats values < 1 as fraction and multiplies by 100 (0.5 → 50%)", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 0.5,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(50);
|
||||
});
|
||||
|
||||
it("treats value exactly 1.0 as 1% (not 100%) — the < 1 heuristic boundary", async () => {
|
||||
// 1.0 is NOT < 1, so it is treated as already-percentage → 1%
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 1.0,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(1);
|
||||
});
|
||||
|
||||
it("treats value 0 as 0%", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 0,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps 100% to 100 (no overshoot)", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 105,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(100);
|
||||
});
|
||||
|
||||
it("sets usedPercent to null when used_percent is absent", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readClaudeToken — filesystem paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readClaudeToken", () => {
|
||||
const savedEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = savedEnv;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when credentials.json does not exist", async () => {
|
||||
// Point to a directory that does not have credentials.json
|
||||
process.env.CLAUDE_CONFIG_DIR = "/tmp/__no_such_paperclip_dir__";
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "credentials.json"), "not-json"),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns null when claudeAiOauth key is missing", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "credentials.json"), JSON.stringify({ other: "data" })),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns null when accessToken is an empty string", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
const creds = { claudeAiOauth: { accessToken: "" } };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "credentials.json"), JSON.stringify(creds)),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns the token when credentials file is well-formed", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
const creds = { claudeAiOauth: { accessToken: "my-test-token" } };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "credentials.json"), JSON.stringify(creds)),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe("my-test-token");
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("reads the token from .credentials.json when that is the available Claude auth file", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
const creds = { claudeAiOauth: { accessToken: "dotfile-token" } };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, ".credentials.json"), JSON.stringify(creds)),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe("dotfile-token");
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClaudeCliUsageText", () => {
|
||||
it("parses the Claude usage panel layout into quota windows", () => {
|
||||
const raw = `
|
||||
Settings: Status Config Usage
|
||||
Current session
|
||||
2% used
|
||||
Resets 5pm (America/Chicago)
|
||||
|
||||
Current week (all models)
|
||||
47% used
|
||||
Resets Mar 18 at 7:59am (America/Chicago)
|
||||
|
||||
Current week (Sonnet only)
|
||||
0% used
|
||||
Resets Mar 18 at 8:59am (America/Chicago)
|
||||
|
||||
Extra usage
|
||||
Extra usage not enabled • /extra-usage to enable
|
||||
`;
|
||||
|
||||
expect(parseClaudeCliUsageText(raw)).toEqual([
|
||||
{
|
||||
label: "Current session",
|
||||
usedPercent: 2,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets 5pm (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Current week (all models)",
|
||||
usedPercent: 47,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets Mar 18 at 7:59am (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Current week (Sonnet only)",
|
||||
usedPercent: 0,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets Mar 18 at 8:59am (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Extra usage",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Extra usage not enabled • /extra-usage to enable",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws a useful error when the Claude CLI panel reports a usage load failure", () => {
|
||||
expect(() => parseClaudeCliUsageText("Failed to load usage data")).toThrow(
|
||||
"Claude CLI could not load usage data. Open the CLI and retry `/usage`.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readCodexAuthInfo / readCodexToken — filesystem paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readCodexAuthInfo", () => {
|
||||
const savedEnv = process.env.CODEX_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = savedEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when auth.json does not exist", async () => {
|
||||
process.env.CODEX_HOME = "/tmp/__no_such_paperclip_codex_dir__";
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), "{bad json"),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns null when accessToken is absent", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({ accountId: "acc-1" })),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("reads the legacy flat auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
const auth = { accessToken: "codex-token", accountId: "acc-123" };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify(auth)),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toMatchObject({
|
||||
accessToken: "codex-token",
|
||||
accountId: "acc-123",
|
||||
email: null,
|
||||
planType: null,
|
||||
});
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("reads the modern nested auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
const jwtPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
email: "codex@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_plan_type: "pro",
|
||||
chatgpt_user_email: "codex@example.com",
|
||||
},
|
||||
}),
|
||||
).toString("base64url");
|
||||
const auth = {
|
||||
tokens: {
|
||||
access_token: `header.${jwtPayload}.sig`,
|
||||
account_id: "acc-modern",
|
||||
refresh_token: "refresh-me",
|
||||
id_token: `header.${jwtPayload}.sig`,
|
||||
},
|
||||
last_refresh: "2026-03-14T12:00:00Z",
|
||||
};
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify(auth)),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toMatchObject({
|
||||
accessToken: `header.${jwtPayload}.sig`,
|
||||
accountId: "acc-modern",
|
||||
refreshToken: "refresh-me",
|
||||
email: "codex@example.com",
|
||||
planType: "pro",
|
||||
lastRefresh: "2026-03-14T12:00:00Z",
|
||||
});
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCodexToken", () => {
|
||||
const savedEnv = process.env.CODEX_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = savedEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns token and accountId from the nested auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "nested-token",
|
||||
account_id: "acc-nested",
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexToken();
|
||||
expect(result).toEqual({ token: "nested-token", accountId: "acc-nested" });
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchClaudeQuota — response parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("fetchClaudeQuota", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetch(body: unknown, ok = true, status = 200) {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
it("throws when the API returns a non-200 status", async () => {
|
||||
mockFetch({}, false, 401);
|
||||
await expect(fetchClaudeQuota("token")).rejects.toThrow("anthropic usage api returned 401");
|
||||
});
|
||||
|
||||
it("returns an empty array when all window fields are absent", async () => {
|
||||
mockFetch({});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses five_hour window", async () => {
|
||||
mockFetch({ five_hour: { utilization: 0.4, resets_at: "2026-01-01T00:00:00Z" } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Current session",
|
||||
usedPercent: 40,
|
||||
resetsAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses seven_day window", async () => {
|
||||
mockFetch({ seven_day: { utilization: 0.75, resets_at: null } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Current week (all models)",
|
||||
usedPercent: 75,
|
||||
resetsAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses seven_day_sonnet and seven_day_opus windows", async () => {
|
||||
mockFetch({
|
||||
seven_day_sonnet: { utilization: 0.2, resets_at: null },
|
||||
seven_day_opus: { utilization: 0.9, resets_at: null },
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0]!.label).toBe("Current week (Sonnet only)");
|
||||
expect(windows[1]!.label).toBe("Current week (Opus only)");
|
||||
});
|
||||
|
||||
it("sets usedPercent to null when utilization is absent", async () => {
|
||||
mockFetch({ five_hour: { resets_at: null } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows[0]!.usedPercent).toBe(null);
|
||||
});
|
||||
|
||||
it("includes all four windows when all are present", async () => {
|
||||
mockFetch({
|
||||
five_hour: { utilization: 0.1, resets_at: null },
|
||||
seven_day: { utilization: 0.2, resets_at: null },
|
||||
seven_day_sonnet: { utilization: 0.3, resets_at: null },
|
||||
seven_day_opus: { utilization: 0.4, resets_at: null },
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(4);
|
||||
const labels = windows.map((w: QuotaWindow) => w.label);
|
||||
expect(labels).toEqual([
|
||||
"Current session",
|
||||
"Current week (all models)",
|
||||
"Current week (Sonnet only)",
|
||||
"Current week (Opus only)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses extra usage when the OAuth response includes it", async () => {
|
||||
mockFetch({
|
||||
extra_usage: {
|
||||
is_enabled: false,
|
||||
utilization: null,
|
||||
},
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toEqual([
|
||||
{
|
||||
label: "Extra usage",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: "Not enabled",
|
||||
detail: "Extra usage not enabled",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchCodexQuota — response parsing (credits, windows)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("fetchCodexQuota", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetch(body: unknown, ok = true, status = 200) {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
it("throws when the WHAM API returns a non-200 status", async () => {
|
||||
mockFetch({}, false, 403);
|
||||
await expect(fetchCodexQuota("token", null)).rejects.toThrow("chatgpt wham api returned 403");
|
||||
});
|
||||
|
||||
it("passes ChatGPT-Account-Id header when accountId is provided", async () => {
|
||||
mockFetch({});
|
||||
await fetchCodexQuota("token", "acc-xyz");
|
||||
const callInit = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1] as RequestInit;
|
||||
expect((callInit.headers as Record<string, string>)["ChatGPT-Account-Id"]).toBe("acc-xyz");
|
||||
});
|
||||
|
||||
it("omits ChatGPT-Account-Id header when accountId is null", async () => {
|
||||
mockFetch({});
|
||||
await fetchCodexQuota("token", null);
|
||||
const callInit = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1] as RequestInit;
|
||||
expect((callInit.headers as Record<string, string>)["ChatGPT-Account-Id"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns empty array when response body is empty", async () => {
|
||||
mockFetch({});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes numeric reset timestamps from WHAM", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 30, limit_window_seconds: 86400, reset_at: 1_767_312_000 },
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({ label: "5h limit", usedPercent: 30, resetsAt: "2026-01-02T00:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("parses secondary_window alongside primary_window", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10, limit_window_seconds: 18000 },
|
||||
secondary_window: { used_percent: 60, limit_window_seconds: 604800 },
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0]!.label).toBe("5h limit");
|
||||
expect(windows[1]!.label).toBe("Weekly limit");
|
||||
});
|
||||
|
||||
it("includes Credits window when credits present and not unlimited", async () => {
|
||||
mockFetch({
|
||||
credits: { balance: 420, unlimited: false },
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({ label: "Credits", valueLabel: "$4.20 remaining", usedPercent: null });
|
||||
});
|
||||
|
||||
it("omits Credits window when unlimited is true", async () => {
|
||||
mockFetch({
|
||||
credits: { balance: 9999, unlimited: true },
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows 'N/A' valueLabel when credits balance is null", async () => {
|
||||
mockFetch({
|
||||
credits: { balance: null, unlimited: false },
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.valueLabel).toBe("N/A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapCodexRpcQuota", () => {
|
||||
it("maps account and model-specific Codex limits into quota windows", () => {
|
||||
const snapshot = mapCodexRpcQuota(
|
||||
{
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
primary: { usedPercent: 1, windowDurationMins: 300, resetsAt: 1_763_500_000 },
|
||||
secondary: { usedPercent: 27, windowDurationMins: 10_080 },
|
||||
planType: "pro",
|
||||
},
|
||||
rateLimitsByLimitId: {
|
||||
codex_bengalfox: {
|
||||
limitId: "codex_bengalfox",
|
||||
limitName: "GPT-5.3-Codex-Spark",
|
||||
primary: { usedPercent: 8, windowDurationMins: 300 },
|
||||
secondary: { usedPercent: 20, windowDurationMins: 10_080 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
account: {
|
||||
email: "codex@example.com",
|
||||
planType: "pro",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(snapshot.email).toBe("codex@example.com");
|
||||
expect(snapshot.planType).toBe("pro");
|
||||
expect(snapshot.windows).toEqual([
|
||||
{
|
||||
label: "5h limit",
|
||||
usedPercent: 1,
|
||||
resetsAt: "2025-11-18T21:06:40.000Z",
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "Weekly limit",
|
||||
usedPercent: 27,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "GPT-5.3-Codex-Spark · 5h limit",
|
||||
usedPercent: 8,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "GPT-5.3-Codex-Spark · Weekly limit",
|
||||
usedPercent: 20,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes a credits row when the root Codex limit reports finite credits", () => {
|
||||
const snapshot = mapCodexRpcQuota({
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
credits: {
|
||||
unlimited: false,
|
||||
balance: "12.34",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.windows).toEqual([
|
||||
{
|
||||
label: "Credits",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: "$12.34 remaining",
|
||||
detail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchWithTimeout — abort on timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("fetchWithTimeout", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resolves normally when fetch completes before timeout", async () => {
|
||||
const mockResponse = { ok: true, status: 200, json: async () => ({}) } as Response;
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockResponse));
|
||||
|
||||
const result = await fetchWithTimeout("https://example.com", {}, 5000);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects with abort error when fetch takes too long", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("The operation was aborted.", "AbortError"));
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const promise = fetchWithTimeout("https://example.com", {}, 1000);
|
||||
vi.advanceTimersByTime(1001);
|
||||
await expect(promise).rejects.toThrow("aborted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { REDACTED_EVENT_VALUE, redactEventPayload, sanitizeRecord } from "../redaction.js";
|
||||
|
||||
describe("redaction", () => {
|
||||
it("redacts sensitive keys and nested secret values", () => {
|
||||
const input = {
|
||||
apiKey: "abc123",
|
||||
nested: {
|
||||
AUTH_TOKEN: "token-value",
|
||||
safe: "ok",
|
||||
},
|
||||
env: {
|
||||
OPENAI_API_KEY: "sk-openai",
|
||||
OPENAI_API_KEY_REF: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
},
|
||||
OPENAI_API_KEY_PLAIN: {
|
||||
type: "plain",
|
||||
value: "sk-plain",
|
||||
},
|
||||
PAPERCLIP_API_URL: "http://localhost:3100",
|
||||
},
|
||||
};
|
||||
|
||||
const result = sanitizeRecord(input);
|
||||
|
||||
expect(result.apiKey).toBe(REDACTED_EVENT_VALUE);
|
||||
expect(result.nested).toEqual({
|
||||
AUTH_TOKEN: REDACTED_EVENT_VALUE,
|
||||
safe: "ok",
|
||||
});
|
||||
expect(result.env).toEqual({
|
||||
OPENAI_API_KEY: REDACTED_EVENT_VALUE,
|
||||
OPENAI_API_KEY_REF: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
},
|
||||
OPENAI_API_KEY_PLAIN: {
|
||||
type: "plain",
|
||||
value: REDACTED_EVENT_VALUE,
|
||||
},
|
||||
PAPERCLIP_API_URL: "http://localhost:3100",
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts jwt-looking values even when key name is not sensitive", () => {
|
||||
const input = {
|
||||
session: "aaa.bbb.ccc",
|
||||
normal: "plain",
|
||||
};
|
||||
|
||||
const result = sanitizeRecord(input);
|
||||
|
||||
expect(result.session).toBe(REDACTED_EVENT_VALUE);
|
||||
expect(result.normal).toBe("plain");
|
||||
});
|
||||
|
||||
it("redacts payload objects while preserving null", () => {
|
||||
expect(redactEventPayload(null)).toBeNull();
|
||||
expect(redactEventPayload({ password: "hunter2", safe: "value" })).toEqual({
|
||||
password: REDACTED_EVENT_VALUE,
|
||||
safe: "value",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
applyPendingMigrations,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
instanceSettings,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
projects,
|
||||
routineRuns,
|
||||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
|
||||
vi.mock("../services/index.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/index.js")>("../services/index.js");
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
const { heartbeatRuns, issues } = await import("@paperclipai/db");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
routineService: (db: any) =>
|
||||
actual.routineService(db, {
|
||||
heartbeat: {
|
||||
wakeup: async (agentId: string, wakeupOpts: any) => {
|
||||
const issueId =
|
||||
(typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
|
||||
(typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
|
||||
null;
|
||||
if (!issueId) return null;
|
||||
|
||||
const issue = await db
|
||||
.select({ companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows: Array<{ companyId: string }>) => rows[0] ?? null);
|
||||
if (!issue) return null;
|
||||
|
||||
const queuedRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: queuedRunId,
|
||||
companyId: issue.companyId,
|
||||
agentId,
|
||||
invocationSource: wakeupOpts?.source ?? "assignment",
|
||||
triggerDetail: wakeupOpts?.triggerDetail ?? null,
|
||||
status: "queued",
|
||||
contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId },
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: queuedRunId,
|
||||
executionLockedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
return { id: queuedRunId };
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
return { connectionString, dataDir, instance };
|
||||
}
|
||||
|
||||
describe("routine routes end-to-end", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
let dataDir = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startTempDatabase();
|
||||
db = createDb(started.connectionString);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(routineRuns);
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(issues);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(routines);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
await db.delete(instanceSettings);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const { routineRoutes } = await import("../routes/routines.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", routineRoutes(db));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seedFixture() {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const userId = randomUUID();
|
||||
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: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Routine Project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const access = accessService(db);
|
||||
const membership = await access.ensureMembership(companyId, "user", userId, "owner", "active");
|
||||
await access.setMemberPermissions(
|
||||
companyId,
|
||||
membership.id,
|
||||
[{ permissionKey: "tasks:assign" }],
|
||||
userId,
|
||||
);
|
||||
|
||||
return { companyId, agentId, projectId, userId };
|
||||
}
|
||||
|
||||
it("supports creating, scheduling, and manually running a routine through the API", async () => {
|
||||
const { companyId, agentId, projectId, userId } = await seedFixture();
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId,
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/companies/${companyId}/routines`)
|
||||
.send({
|
||||
projectId,
|
||||
title: "Daily standup prep",
|
||||
description: "Summarize blockers and open PRs",
|
||||
assigneeAgentId: agentId,
|
||||
priority: "high",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
});
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
expect(createRes.body.title).toBe("Daily standup prep");
|
||||
expect(createRes.body.assigneeAgentId).toBe(agentId);
|
||||
|
||||
const routineId = createRes.body.id as string;
|
||||
|
||||
const triggerRes = await request(app)
|
||||
.post(`/api/routines/${routineId}/triggers`)
|
||||
.send({
|
||||
kind: "schedule",
|
||||
label: "Weekday morning",
|
||||
cronExpression: "0 10 * * 1-5",
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
expect(triggerRes.status).toBe(201);
|
||||
expect(triggerRes.body.trigger.kind).toBe("schedule");
|
||||
expect(triggerRes.body.trigger.enabled).toBe(true);
|
||||
expect(triggerRes.body.secretMaterial).toBeNull();
|
||||
|
||||
const runRes = await request(app)
|
||||
.post(`/api/routines/${routineId}/run`)
|
||||
.send({
|
||||
source: "manual",
|
||||
payload: { origin: "e2e-test" },
|
||||
});
|
||||
|
||||
expect(runRes.status).toBe(202);
|
||||
expect(runRes.body.status).toBe("issue_created");
|
||||
expect(runRes.body.source).toBe("manual");
|
||||
expect(runRes.body.linkedIssueId).toBeTruthy();
|
||||
|
||||
const detailRes = await request(app).get(`/api/routines/${routineId}`);
|
||||
expect(detailRes.status).toBe(200);
|
||||
expect(detailRes.body.triggers).toHaveLength(1);
|
||||
expect(detailRes.body.triggers[0]?.id).toBe(triggerRes.body.trigger.id);
|
||||
expect(detailRes.body.recentRuns).toHaveLength(1);
|
||||
expect(detailRes.body.recentRuns[0]?.id).toBe(runRes.body.id);
|
||||
expect(detailRes.body.activeIssue?.id).toBe(runRes.body.linkedIssueId);
|
||||
|
||||
const runsRes = await request(app).get(`/api/routines/${routineId}/runs?limit=10`);
|
||||
expect(runsRes.status).toBe(200);
|
||||
expect(runsRes.body).toHaveLength(1);
|
||||
expect(runsRes.body[0]?.id).toBe(runRes.body.id);
|
||||
|
||||
const [issue] = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
originId: issues.originId,
|
||||
originKind: issues.originKind,
|
||||
executionRunId: issues.executionRunId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, runRes.body.linkedIssueId));
|
||||
|
||||
expect(issue).toMatchObject({
|
||||
id: runRes.body.linkedIssueId,
|
||||
originId: routineId,
|
||||
originKind: "routine_execution",
|
||||
});
|
||||
expect(issue?.executionRunId).toBeTruthy();
|
||||
|
||||
const actions = await db
|
||||
.select({
|
||||
action: activityLog.action,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.companyId, companyId));
|
||||
|
||||
expect(actions.map((entry) => entry.action)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"routine.created",
|
||||
"routine.trigger_created",
|
||||
"routine.run_triggered",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { routineRoutes } from "../routes/routines.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
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 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",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
lastTriggeredAt: null,
|
||||
lastEnqueuedAt: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
};
|
||||
const pausedRoutine = {
|
||||
...routine,
|
||||
status: "paused",
|
||||
};
|
||||
const trigger = {
|
||||
id: "66666666-6666-4666-8666-666666666666",
|
||||
companyId,
|
||||
routineId,
|
||||
kind: "schedule",
|
||||
label: "weekday",
|
||||
enabled: false,
|
||||
cronExpression: "0 10 * * 1-5",
|
||||
timezone: "UTC",
|
||||
nextRunAt: null,
|
||||
lastFiredAt: null,
|
||||
publicId: null,
|
||||
secretId: null,
|
||||
signingMode: null,
|
||||
replayWindowSec: null,
|
||||
lastRotatedAt: null,
|
||||
lastResult: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const mockRoutineService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getDetail: vi.fn(),
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
listRuns: vi.fn(),
|
||||
createTrigger: vi.fn(),
|
||||
getTrigger: vi.fn(),
|
||||
updateTrigger: vi.fn(),
|
||||
deleteTrigger: vi.fn(),
|
||||
rotateTriggerSecret: vi.fn(),
|
||||
runRoutine: vi.fn(),
|
||||
firePublicTrigger: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
logActivity: mockLogActivity,
|
||||
routineService: () => mockRoutineService,
|
||||
}));
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", routineRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("routine routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRoutineService.create.mockResolvedValue(routine);
|
||||
mockRoutineService.get.mockResolvedValue(routine);
|
||||
mockRoutineService.getTrigger.mockResolvedValue(trigger);
|
||||
mockRoutineService.update.mockResolvedValue({ ...routine, assigneeAgentId: otherAgentId });
|
||||
mockRoutineService.runRoutine.mockResolvedValue({
|
||||
id: "run-1",
|
||||
source: "manual",
|
||||
status: "issue_created",
|
||||
});
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission for non-admin board routine creation", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/routines`)
|
||||
.send({
|
||||
projectId,
|
||||
title: "Daily routine",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("tasks:assign");
|
||||
expect(mockRoutineService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission to retarget a routine assignee", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/routines/${routineId}`)
|
||||
.send({
|
||||
assigneeAgentId: otherAgentId,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("tasks:assign");
|
||||
expect(mockRoutineService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission to reactivate a routine", async () => {
|
||||
mockRoutineService.get.mockResolvedValue(pausedRoutine);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/routines/${routineId}`)
|
||||
.send({
|
||||
status: "active",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("tasks:assign");
|
||||
expect(mockRoutineService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission to create a trigger", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/routines/${routineId}/triggers`)
|
||||
.send({
|
||||
kind: "schedule",
|
||||
cronExpression: "0 10 * * *",
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("tasks:assign");
|
||||
expect(mockRoutineService.createTrigger).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission to update a trigger", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/routine-triggers/${trigger.id}`)
|
||||
.send({
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("tasks:assign");
|
||||
expect(mockRoutineService.updateTrigger).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission to manually run a routine", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/routines/${routineId}/run`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("tasks:assign");
|
||||
expect(mockRoutineService.runRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows routine creation when the board user has tasks:assign", async () => {
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/routines`)
|
||||
.send({
|
||||
projectId,
|
||||
title: "Daily routine",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockRoutineService.create).toHaveBeenCalledWith(companyId, expect.objectContaining({
|
||||
projectId,
|
||||
title: "Daily routine",
|
||||
assigneeAgentId: agentId,
|
||||
}), {
|
||||
agentId: null,
|
||||
userId: "board-user",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
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,
|
||||
agents,
|
||||
applyPendingMigrations,
|
||||
companies,
|
||||
companySecrets,
|
||||
companySecretVersions,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projects,
|
||||
routineRuns,
|
||||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
import { routineService } from "../services/routines.ts";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-service-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
return { connectionString, dataDir, instance };
|
||||
}
|
||||
|
||||
describe("routine service live-execution coalescing", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
let dataDir = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startTempDatabase();
|
||||
db = createDb(started.connectionString);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(routineRuns);
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(routines);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function seedFixture(opts?: {
|
||||
wakeup?: (
|
||||
agentId: string,
|
||||
wakeupOpts: {
|
||||
source?: string;
|
||||
triggerDetail?: string;
|
||||
reason?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
requestedByActorType?: "user" | "agent" | "system";
|
||||
requestedByActorId?: string | null;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const wakeups: Array<{
|
||||
agentId: string;
|
||||
opts: {
|
||||
source?: string;
|
||||
triggerDetail?: string;
|
||||
reason?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
requestedByActorType?: "user" | "agent" | "system";
|
||||
requestedByActorId?: string | null;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
};
|
||||
}> = [];
|
||||
|
||||
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: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Routines",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const svc = routineService(db, {
|
||||
heartbeat: {
|
||||
wakeup: async (wakeupAgentId, wakeupOpts) => {
|
||||
wakeups.push({ agentId: wakeupAgentId, opts: wakeupOpts });
|
||||
if (opts?.wakeup) return opts.wakeup(wakeupAgentId, wakeupOpts);
|
||||
const issueId =
|
||||
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
|
||||
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
|
||||
null;
|
||||
if (!issueId) return null;
|
||||
const queuedRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: queuedRunId,
|
||||
companyId,
|
||||
agentId: wakeupAgentId,
|
||||
invocationSource: wakeupOpts.source ?? "assignment",
|
||||
triggerDetail: wakeupOpts.triggerDetail ?? null,
|
||||
status: "queued",
|
||||
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: queuedRunId,
|
||||
executionLockedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
return { id: queuedRunId };
|
||||
},
|
||||
},
|
||||
});
|
||||
const issueSvc = issueService(db);
|
||||
const routine = await svc.create(
|
||||
companyId,
|
||||
{
|
||||
projectId,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "ascii frog",
|
||||
description: "Run the frog routine",
|
||||
assigneeAgentId: agentId,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
|
||||
}
|
||||
|
||||
it("creates a fresh execution issue when the previous routine issue is open but idle", async () => {
|
||||
const { companyId, issueSvc, routine, svc } = await seedFixture();
|
||||
const previousRunId = randomUUID();
|
||||
const previousIssue = await issueSvc.create(companyId, {
|
||||
projectId: routine.projectId,
|
||||
title: routine.title,
|
||||
description: routine.description,
|
||||
status: "todo",
|
||||
priority: routine.priority,
|
||||
assigneeAgentId: routine.assigneeAgentId,
|
||||
originKind: "routine_execution",
|
||||
originId: routine.id,
|
||||
originRunId: previousRunId,
|
||||
});
|
||||
|
||||
await db.insert(routineRuns).values({
|
||||
id: previousRunId,
|
||||
companyId,
|
||||
routineId: routine.id,
|
||||
triggerId: null,
|
||||
source: "manual",
|
||||
status: "issue_created",
|
||||
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
|
||||
linkedIssueId: previousIssue.id,
|
||||
completedAt: new Date("2026-03-20T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
const detailBefore = await svc.getDetail(routine.id);
|
||||
expect(detailBefore?.activeIssue).toBeNull();
|
||||
|
||||
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||
expect(run.status).toBe("issue_created");
|
||||
expect(run.linkedIssueId).not.toBe(previousIssue.id);
|
||||
|
||||
const routineIssues = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
originRunId: issues.originRunId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.originId, routine.id));
|
||||
|
||||
expect(routineIssues).toHaveLength(2);
|
||||
expect(routineIssues.map((issue) => issue.id)).toContain(previousIssue.id);
|
||||
expect(routineIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
|
||||
});
|
||||
|
||||
it("wakes the assignee when a routine creates a fresh execution issue", async () => {
|
||||
const { agentId, routine, svc, wakeups } = await seedFixture();
|
||||
|
||||
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||
|
||||
expect(run.status).toBe("issue_created");
|
||||
expect(run.linkedIssueId).toBeTruthy();
|
||||
expect(wakeups).toEqual([
|
||||
{
|
||||
agentId,
|
||||
opts: {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: { issueId: run.linkedIssueId, mutation: "create" },
|
||||
requestedByActorType: undefined,
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: { issueId: run.linkedIssueId, source: "routine.dispatch" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits for the assignee wakeup to be queued before returning the routine run", async () => {
|
||||
let wakeupResolved = false;
|
||||
const { routine, svc } = await seedFixture({
|
||||
wakeup: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
wakeupResolved = true;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||
|
||||
expect(run.status).toBe("issue_created");
|
||||
expect(wakeupResolved).toBe(true);
|
||||
});
|
||||
|
||||
it("coalesces only when the existing routine issue has a live execution run", async () => {
|
||||
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
||||
const previousRunId = randomUUID();
|
||||
const liveHeartbeatRunId = randomUUID();
|
||||
const previousIssue = await issueSvc.create(companyId, {
|
||||
projectId: routine.projectId,
|
||||
title: routine.title,
|
||||
description: routine.description,
|
||||
status: "in_progress",
|
||||
priority: routine.priority,
|
||||
assigneeAgentId: routine.assigneeAgentId,
|
||||
originKind: "routine_execution",
|
||||
originId: routine.id,
|
||||
originRunId: previousRunId,
|
||||
});
|
||||
|
||||
await db.insert(routineRuns).values({
|
||||
id: previousRunId,
|
||||
companyId,
|
||||
routineId: routine.id,
|
||||
triggerId: null,
|
||||
source: "manual",
|
||||
status: "issue_created",
|
||||
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
|
||||
linkedIssueId: previousIssue.id,
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: liveHeartbeatRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
status: "running",
|
||||
contextSnapshot: { issueId: previousIssue.id },
|
||||
startedAt: new Date("2026-03-20T12:01:00.000Z"),
|
||||
});
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
checkoutRunId: liveHeartbeatRunId,
|
||||
executionRunId: liveHeartbeatRunId,
|
||||
executionLockedAt: new Date("2026-03-20T12:01:00.000Z"),
|
||||
})
|
||||
.where(eq(issues.id, previousIssue.id));
|
||||
|
||||
const detailBefore = await svc.getDetail(routine.id);
|
||||
expect(detailBefore?.activeIssue?.id).toBe(previousIssue.id);
|
||||
|
||||
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||
expect(run.status).toBe("coalesced");
|
||||
expect(run.linkedIssueId).toBe(previousIssue.id);
|
||||
expect(run.coalescedIntoRunId).toBe(previousRunId);
|
||||
|
||||
const routineIssues = await db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(eq(issues.originId, routine.id));
|
||||
|
||||
expect(routineIssues).toHaveLength(1);
|
||||
expect(routineIssues[0]?.id).toBe(previousIssue.id);
|
||||
});
|
||||
|
||||
it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => {
|
||||
const { routine, svc } = await seedFixture({
|
||||
wakeup: async (wakeupAgentId, wakeupOpts) => {
|
||||
const issueId =
|
||||
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
|
||||
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
|
||||
null;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
if (!issueId) return null;
|
||||
const queuedRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: queuedRunId,
|
||||
companyId: routine.companyId,
|
||||
agentId: wakeupAgentId,
|
||||
invocationSource: wakeupOpts.source ?? "assignment",
|
||||
triggerDetail: wakeupOpts.triggerDetail ?? null,
|
||||
status: "queued",
|
||||
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: queuedRunId,
|
||||
executionLockedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
return { id: queuedRunId };
|
||||
},
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
svc.runRoutine(routine.id, { source: "manual" }),
|
||||
svc.runRoutine(routine.id, { source: "manual" }),
|
||||
]);
|
||||
|
||||
expect([first.status, second.status].sort()).toEqual(["coalesced", "issue_created"]);
|
||||
expect(first.linkedIssueId).toBeTruthy();
|
||||
expect(second.linkedIssueId).toBeTruthy();
|
||||
expect(first.linkedIssueId).toBe(second.linkedIssueId);
|
||||
|
||||
const routineIssues = await db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(eq(issues.originId, routine.id));
|
||||
|
||||
expect(routineIssues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails the run and cleans up the execution issue when wakeup queueing fails", async () => {
|
||||
const { routine, svc } = await seedFixture({
|
||||
wakeup: async () => {
|
||||
throw new Error("queue unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||
|
||||
expect(run.status).toBe("failed");
|
||||
expect(run.failureReason).toContain("queue unavailable");
|
||||
expect(run.linkedIssueId).toBeNull();
|
||||
|
||||
const routineIssues = await db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(eq(issues.originId, routine.id));
|
||||
|
||||
expect(routineIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts standard second-precision webhook timestamps for HMAC triggers", async () => {
|
||||
const { routine, svc } = await seedFixture();
|
||||
const { trigger, secretMaterial } = await svc.createTrigger(
|
||||
routine.id,
|
||||
{
|
||||
kind: "webhook",
|
||||
signingMode: "hmac_sha256",
|
||||
replayWindowSec: 300,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(trigger.publicId).toBeTruthy();
|
||||
expect(secretMaterial?.webhookSecret).toBeTruthy();
|
||||
|
||||
const payload = { ok: true };
|
||||
const rawBody = Buffer.from(JSON.stringify(payload));
|
||||
const timestampSeconds = String(Math.floor(Date.now() / 1000));
|
||||
const signature = `sha256=${createHmac("sha256", secretMaterial!.webhookSecret)
|
||||
.update(`${timestampSeconds}.`)
|
||||
.update(rawBody)
|
||||
.digest("hex")}`;
|
||||
|
||||
const run = await svc.firePublicTrigger(trigger.publicId!, {
|
||||
signatureHeader: signature,
|
||||
timestampHeader: timestampSeconds,
|
||||
rawBody,
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(run.source).toBe("webhook");
|
||||
expect(run.status).toBe("issue_created");
|
||||
expect(run.linkedIssueId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { createStorageService } from "../storage/service.js";
|
||||
|
||||
async function readStreamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
describe("local disk storage provider", () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
it("round-trips bytes through storage service", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
||||
tempRoots.push(root);
|
||||
|
||||
const service = createStorageService(createLocalDiskStorageProvider(root));
|
||||
const content = Buffer.from("hello image bytes", "utf8");
|
||||
const stored = await service.putFile({
|
||||
companyId: "company-1",
|
||||
namespace: "issues/issue-1",
|
||||
originalFilename: "demo.png",
|
||||
contentType: "image/png",
|
||||
body: content,
|
||||
});
|
||||
|
||||
const fetched = await service.getObject("company-1", stored.objectKey);
|
||||
const fetchedBody = await readStreamToBuffer(fetched.stream);
|
||||
|
||||
expect(fetchedBody.toString("utf8")).toBe("hello image bytes");
|
||||
expect(stored.sha256).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("blocks cross-company object access", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
||||
tempRoots.push(root);
|
||||
|
||||
const service = createStorageService(createLocalDiskStorageProvider(root));
|
||||
const stored = await service.putFile({
|
||||
companyId: "company-a",
|
||||
namespace: "issues/issue-1",
|
||||
originalFilename: "demo.png",
|
||||
contentType: "image/png",
|
||||
body: Buffer.from("hello", "utf8"),
|
||||
});
|
||||
|
||||
await expect(service.getObject("company-b", stored.objectKey)).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it("delete is idempotent", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
||||
tempRoots.push(root);
|
||||
|
||||
const service = createStorageService(createLocalDiskStorageProvider(root));
|
||||
const stored = await service.putFile({
|
||||
companyId: "company-1",
|
||||
namespace: "issues/issue-1",
|
||||
originalFilename: "demo.png",
|
||||
contentType: "image/png",
|
||||
body: Buffer.from("hello", "utf8"),
|
||||
});
|
||||
|
||||
await service.deleteObject("company-1", stored.objectKey);
|
||||
await service.deleteObject("company-1", stored.objectKey);
|
||||
await expect(service.getObject("company-1", stored.objectKey)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyUiBranding,
|
||||
getWorktreeUiBranding,
|
||||
isWorktreeUiBrandingEnabled,
|
||||
renderFaviconLinks,
|
||||
renderRuntimeBrandingMeta,
|
||||
} from "../ui-branding.js";
|
||||
|
||||
const TEMPLATE = `<!doctype html>
|
||||
<head>
|
||||
<!-- PAPERCLIP_RUNTIME_BRANDING_START -->
|
||||
<!-- PAPERCLIP_RUNTIME_BRANDING_END -->
|
||||
<!-- PAPERCLIP_FAVICON_START -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<!-- PAPERCLIP_FAVICON_END -->
|
||||
</head>`;
|
||||
|
||||
describe("ui branding", () => {
|
||||
it("detects worktree mode from PAPERCLIP_IN_WORKTREE", () => {
|
||||
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "true" })).toBe(true);
|
||||
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "1" })).toBe(true);
|
||||
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves name, color, and text color for worktree branding", () => {
|
||||
const branding = getWorktreeUiBranding({
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||
});
|
||||
|
||||
expect(branding.enabled).toBe(true);
|
||||
expect(branding.name).toBe("paperclip-pr-432");
|
||||
expect(branding.color).toBe("#4f86f7");
|
||||
expect(branding.textColor).toMatch(/^#[0-9a-f]{6}$/);
|
||||
expect(branding.faviconHref).toContain("data:image/svg+xml,");
|
||||
});
|
||||
|
||||
it("renders a dynamic worktree favicon when enabled", () => {
|
||||
const links = renderFaviconLinks(
|
||||
getWorktreeUiBranding({
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||
}),
|
||||
);
|
||||
expect(links).toContain("data:image/svg+xml,");
|
||||
expect(links).toContain('rel="shortcut icon"');
|
||||
});
|
||||
|
||||
it("renders runtime branding metadata for the ui", () => {
|
||||
const meta = renderRuntimeBrandingMeta(
|
||||
getWorktreeUiBranding({
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||
}),
|
||||
);
|
||||
expect(meta).toContain('name="paperclip-worktree-name"');
|
||||
expect(meta).toContain('content="paperclip-pr-432"');
|
||||
expect(meta).toContain('name="paperclip-worktree-color"');
|
||||
});
|
||||
|
||||
it("rewrites the favicon and runtime branding blocks for worktree instances only", () => {
|
||||
const branded = applyUiBranding(TEMPLATE, {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||
});
|
||||
expect(branded).toContain("data:image/svg+xml,");
|
||||
expect(branded).toContain('name="paperclip-worktree-name"');
|
||||
expect(branded).not.toContain('href="/favicon.svg"');
|
||||
|
||||
const defaultHtml = applyUiBranding(TEMPLATE, {});
|
||||
expect(defaultHtml).toContain('href="/favicon.svg"');
|
||||
expect(defaultHtml).not.toContain('name="paperclip-worktree-name"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { workProductService } from "../services/work-products.ts";
|
||||
|
||||
function createWorkProductRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
const now = new Date("2026-03-17T00:00:00.000Z");
|
||||
return {
|
||||
id: "work-product-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
issueId: "issue-1",
|
||||
executionWorkspaceId: null,
|
||||
runtimeServiceId: null,
|
||||
type: "pull_request",
|
||||
provider: "github",
|
||||
externalId: null,
|
||||
title: "PR 1",
|
||||
url: "https://example.com/pr/1",
|
||||
status: "open",
|
||||
reviewState: "draft",
|
||||
isPrimary: true,
|
||||
healthStatus: "unknown",
|
||||
summary: null,
|
||||
metadata: null,
|
||||
createdByRunId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workProductService", () => {
|
||||
it("uses a transaction when creating a new primary work product", async () => {
|
||||
const updatedWhere = vi.fn(async () => undefined);
|
||||
const updateSet = vi.fn(() => ({ where: updatedWhere }));
|
||||
const txUpdate = vi.fn(() => ({ set: updateSet }));
|
||||
|
||||
const insertedRow = createWorkProductRow();
|
||||
const insertReturning = vi.fn(async () => [insertedRow]);
|
||||
const insertValues = vi.fn(() => ({ returning: insertReturning }));
|
||||
const txInsert = vi.fn(() => ({ values: insertValues }));
|
||||
|
||||
const tx = {
|
||||
update: txUpdate,
|
||||
insert: txInsert,
|
||||
};
|
||||
const transaction = vi.fn(async (callback: (input: typeof tx) => Promise<unknown>) => await callback(tx));
|
||||
|
||||
const svc = workProductService({ transaction } as any);
|
||||
const result = await svc.createForIssue("issue-1", "company-1", {
|
||||
type: "pull_request",
|
||||
provider: "github",
|
||||
title: "PR 1",
|
||||
status: "open",
|
||||
reviewState: "draft",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(txUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(txInsert).toHaveBeenCalledTimes(1);
|
||||
expect(result?.id).toBe("work-product-1");
|
||||
});
|
||||
|
||||
it("uses a transaction when promoting an existing work product to primary", async () => {
|
||||
const existingRow = createWorkProductRow({ isPrimary: false });
|
||||
|
||||
const selectWhere = vi.fn(async () => [existingRow]);
|
||||
const selectFrom = vi.fn(() => ({ where: selectWhere }));
|
||||
const txSelect = vi.fn(() => ({ from: selectFrom }));
|
||||
|
||||
const updateReturning = vi
|
||||
.fn()
|
||||
.mockResolvedValue([createWorkProductRow({ reviewState: "ready_for_review" })]);
|
||||
const updateWhere = vi.fn(() => ({ returning: updateReturning }));
|
||||
const updateSet = vi.fn(() => ({ where: updateWhere }));
|
||||
const txUpdate = vi.fn(() => ({ set: updateSet }));
|
||||
|
||||
const tx = {
|
||||
select: txSelect,
|
||||
update: txUpdate,
|
||||
};
|
||||
const transaction = vi.fn(async (callback: (input: typeof tx) => Promise<unknown>) => await callback(tx));
|
||||
|
||||
const svc = workProductService({ transaction } as any);
|
||||
const result = await svc.update("work-product-1", {
|
||||
isPrimary: true,
|
||||
reviewState: "ready_for_review",
|
||||
});
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(txSelect).toHaveBeenCalledTimes(1);
|
||||
expect(txUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(result?.reviewState).toBe("ready_for_review");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,977 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensureRuntimeServicesForRun,
|
||||
normalizeAdapterManagedRuntimeServices,
|
||||
realizeExecutionWorkspace,
|
||||
releaseRuntimeServicesForRun,
|
||||
stopRuntimeServicesForExecutionWorkspace,
|
||||
type RealizedExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.ts";
|
||||
import type { WorkspaceOperation } from "@paperclipai/shared";
|
||||
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const leasedRunIds = new Set<string>();
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", args, { cwd });
|
||||
}
|
||||
|
||||
async function createTempRepo() {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-"));
|
||||
await runGit(repoRoot, ["init"]);
|
||||
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
|
||||
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "README.md"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
|
||||
await runGit(repoRoot, ["checkout", "-B", "main"]);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function buildWorkspace(cwd: string): RealizedExecutionWorkspace {
|
||||
return {
|
||||
baseCwd: cwd,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
strategy: "project_primary",
|
||||
cwd,
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceOperationRecorderDouble() {
|
||||
const operations: Array<{
|
||||
phase: string;
|
||||
command: string | null;
|
||||
cwd: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
result: {
|
||||
status?: string;
|
||||
exitCode?: number | null;
|
||||
stdout?: string | null;
|
||||
stderr?: string | null;
|
||||
system?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
}> = [];
|
||||
let executionWorkspaceId: string | null = null;
|
||||
|
||||
const recorder: WorkspaceOperationRecorder = {
|
||||
attachExecutionWorkspaceId: async (nextExecutionWorkspaceId) => {
|
||||
executionWorkspaceId = nextExecutionWorkspaceId;
|
||||
},
|
||||
recordOperation: async (input) => {
|
||||
const result = await input.run();
|
||||
operations.push({
|
||||
phase: input.phase,
|
||||
command: input.command ?? null,
|
||||
cwd: input.cwd ?? null,
|
||||
metadata: {
|
||||
...(input.metadata ?? {}),
|
||||
...(executionWorkspaceId ? { executionWorkspaceId } : {}),
|
||||
},
|
||||
result,
|
||||
});
|
||||
return {
|
||||
id: `op-${operations.length}`,
|
||||
companyId: "company-1",
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: "run-1",
|
||||
phase: input.phase,
|
||||
command: input.command ?? null,
|
||||
cwd: input.cwd ?? null,
|
||||
status: (result.status ?? "succeeded") as WorkspaceOperation["status"],
|
||||
exitCode: result.exitCode ?? null,
|
||||
logStore: "local_file",
|
||||
logRef: `op-${operations.length}.ndjson`,
|
||||
logBytes: 0,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
stdoutExcerpt: result.stdout ?? null,
|
||||
stderrExcerpt: result.stderr ?? null,
|
||||
metadata: input.metadata ?? null,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return { recorder, operations };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
Array.from(leasedRunIds).map(async (runId) => {
|
||||
await releaseRuntimeServicesForRun(runId);
|
||||
leasedRunIds.delete(runId);
|
||||
}),
|
||||
);
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.DATABASE_URL;
|
||||
});
|
||||
|
||||
describe("realizeExecutionWorkspace", () => {
|
||||
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
const first = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(first.strategy).toBe("git_worktree");
|
||||
expect(first.created).toBe(true);
|
||||
expect(first.branchName).toBe("PAP-447-add-worktree-support");
|
||||
expect(first.cwd).toContain(path.join(".paperclip", "worktrees"));
|
||||
await expect(fs.stat(path.join(first.cwd, ".git"))).resolves.toBeTruthy();
|
||||
|
||||
const second = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.cwd).toBe(first.cwd);
|
||||
expect(second.branchName).toBe(first.branchName);
|
||||
});
|
||||
|
||||
it("runs a configured provision command inside the derived worktree", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "provision.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-provision-branch",
|
||||
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BASE_CWD\" > .paperclip-provision-base",
|
||||
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_CREATED\" > .paperclip-provision-created",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]);
|
||||
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/provision.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-448",
|
||||
title: "Run provision command",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-branch"), "utf8")).resolves.toBe(
|
||||
"PAP-448-run-provision-command\n",
|
||||
);
|
||||
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-base"), "utf8")).resolves.toBe(
|
||||
`${repoRoot}\n`,
|
||||
);
|
||||
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe(
|
||||
"true\n",
|
||||
);
|
||||
|
||||
const reused = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/provision.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-448",
|
||||
title: "Run provision command",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
|
||||
});
|
||||
|
||||
it("records worktree setup and provision operations when a recorder is provided", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
||||
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "provision.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf 'provisioned\\n'",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add recorder provision script"]);
|
||||
|
||||
await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/provision.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-540",
|
||||
title: "Record workspace operations",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
recorder,
|
||||
});
|
||||
|
||||
expect(operations.map((operation) => operation.phase)).toEqual([
|
||||
"worktree_prepare",
|
||||
"workspace_provision",
|
||||
]);
|
||||
expect(operations[0]?.command).toContain("git worktree add");
|
||||
expect(operations[0]?.metadata).toMatchObject({
|
||||
branchName: "PAP-540-record-workspace-operations",
|
||||
created: true,
|
||||
});
|
||||
expect(operations[1]?.command).toBe("bash ./scripts/provision.sh");
|
||||
});
|
||||
|
||||
it("reuses an existing branch without resetting it when recreating a missing worktree", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-450-recreate-missing-worktree";
|
||||
|
||||
await runGit(repoRoot, ["checkout", "-b", branchName]);
|
||||
await fs.writeFile(path.join(repoRoot, "feature.txt"), "preserve me\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "feature.txt"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add preserved feature"]);
|
||||
const expectedHead = (await execFileAsync("git", ["rev-parse", branchName], { cwd: repoRoot })).stdout.trim();
|
||||
await runGit(repoRoot, ["checkout", "main"]);
|
||||
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-450",
|
||||
title: "Recreate missing worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(workspace.branchName).toBe(branchName);
|
||||
await expect(fs.readFile(path.join(workspace.cwd, "feature.txt"), "utf8")).resolves.toBe("preserve me\n");
|
||||
const actualHead = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspace.cwd })).stdout.trim();
|
||||
expect(actualHead).toBe(expectedHead);
|
||||
});
|
||||
|
||||
it("removes a created git worktree and branch during cleanup", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-449",
|
||||
title: "Cleanup workspace",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
const cleanup = await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
id: "execution-workspace-1",
|
||||
cwd: workspace.cwd,
|
||||
providerType: "git_worktree",
|
||||
providerRef: workspace.worktreePath,
|
||||
branchName: workspace.branchName,
|
||||
repoUrl: workspace.repoUrl,
|
||||
baseRef: workspace.repoRef,
|
||||
projectId: workspace.projectId,
|
||||
projectWorkspaceId: workspace.workspaceId,
|
||||
sourceIssueId: "issue-1",
|
||||
metadata: {
|
||||
createdByRuntime: true,
|
||||
},
|
||||
},
|
||||
projectWorkspace: {
|
||||
cwd: repoRoot,
|
||||
cleanupCommand: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cleanup.cleaned).toBe(true);
|
||||
expect(cleanup.warnings).toEqual([]);
|
||||
await expect(fs.stat(workspace.cwd)).rejects.toThrow();
|
||||
await expect(
|
||||
execFileAsync("git", ["branch", "--list", workspace.branchName!], { cwd: repoRoot }),
|
||||
).resolves.toMatchObject({
|
||||
stdout: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an unmerged runtime-created branch and warns instead of force deleting it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-451",
|
||||
title: "Keep unmerged branch",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await fs.writeFile(path.join(workspace.cwd, "unmerged.txt"), "still here\n", "utf8");
|
||||
await runGit(workspace.cwd, ["add", "unmerged.txt"]);
|
||||
await runGit(workspace.cwd, ["commit", "-m", "Keep unmerged work"]);
|
||||
|
||||
const cleanup = await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
id: "execution-workspace-1",
|
||||
cwd: workspace.cwd,
|
||||
providerType: "git_worktree",
|
||||
providerRef: workspace.worktreePath,
|
||||
branchName: workspace.branchName,
|
||||
repoUrl: workspace.repoUrl,
|
||||
baseRef: workspace.repoRef,
|
||||
projectId: workspace.projectId,
|
||||
projectWorkspaceId: workspace.workspaceId,
|
||||
sourceIssueId: "issue-1",
|
||||
metadata: {
|
||||
createdByRuntime: true,
|
||||
},
|
||||
},
|
||||
projectWorkspace: {
|
||||
cwd: repoRoot,
|
||||
cleanupCommand: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cleanup.cleaned).toBe(true);
|
||||
expect(cleanup.warnings).toHaveLength(1);
|
||||
expect(cleanup.warnings[0]).toContain(`Skipped deleting branch "${workspace.branchName}"`);
|
||||
await expect(
|
||||
execFileAsync("git", ["branch", "--list", workspace.branchName!], { cwd: repoRoot }),
|
||||
).resolves.toMatchObject({
|
||||
stdout: expect.stringContaining(workspace.branchName!),
|
||||
});
|
||||
});
|
||||
|
||||
it("records teardown and cleanup operations when a recorder is provided", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
||||
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-541",
|
||||
title: "Cleanup recorder",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
id: "execution-workspace-1",
|
||||
cwd: workspace.cwd,
|
||||
providerType: "git_worktree",
|
||||
providerRef: workspace.worktreePath,
|
||||
branchName: workspace.branchName,
|
||||
repoUrl: workspace.repoUrl,
|
||||
baseRef: workspace.repoRef,
|
||||
projectId: workspace.projectId,
|
||||
projectWorkspaceId: workspace.workspaceId,
|
||||
sourceIssueId: "issue-1",
|
||||
metadata: {
|
||||
createdByRuntime: true,
|
||||
},
|
||||
},
|
||||
projectWorkspace: {
|
||||
cwd: repoRoot,
|
||||
cleanupCommand: "printf 'cleanup ok\\n'",
|
||||
},
|
||||
recorder,
|
||||
});
|
||||
|
||||
expect(operations.map((operation) => operation.phase)).toEqual([
|
||||
"workspace_teardown",
|
||||
"worktree_cleanup",
|
||||
"worktree_cleanup",
|
||||
]);
|
||||
expect(operations[0]?.command).toBe("printf 'cleanup ok\\n'");
|
||||
expect(operations[1]?.metadata).toMatchObject({
|
||||
cleanupAction: "worktree_remove",
|
||||
});
|
||||
expect(operations[2]?.metadata).toMatchObject({
|
||||
cleanupAction: "branch_delete",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureRuntimeServicesForRun", () => {
|
||||
it("reuses shared runtime services across runs and starts a new service after release", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-"));
|
||||
const workspace = buildWorkspace(workspaceRoot);
|
||||
const serviceCommand =
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"";
|
||||
|
||||
const config = {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command: serviceCommand,
|
||||
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: "project_workspace",
|
||||
stopPolicy: {
|
||||
type: "on_run_finish",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const run1 = "run-1";
|
||||
const run2 = "run-2";
|
||||
leasedRunIds.add(run1);
|
||||
leasedRunIds.add(run2);
|
||||
|
||||
const first = await ensureRuntimeServicesForRun({
|
||||
runId: run1,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(first).toHaveLength(1);
|
||||
expect(first[0]?.reused).toBe(false);
|
||||
expect(first[0]?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
const response = await fetch(first[0]!.url!);
|
||||
expect(await response.text()).toBe("ok");
|
||||
|
||||
const second = await ensureRuntimeServicesForRun({
|
||||
runId: run2,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(second).toHaveLength(1);
|
||||
expect(second[0]?.reused).toBe(true);
|
||||
expect(second[0]?.id).toBe(first[0]?.id);
|
||||
|
||||
await releaseRuntimeServicesForRun(run1);
|
||||
leasedRunIds.delete(run1);
|
||||
await releaseRuntimeServicesForRun(run2);
|
||||
leasedRunIds.delete(run2);
|
||||
|
||||
const run3 = "run-3";
|
||||
leasedRunIds.add(run3);
|
||||
const third = await ensureRuntimeServicesForRun({
|
||||
runId: run3,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(third).toHaveLength(1);
|
||||
expect(third[0]?.reused).toBe(false);
|
||||
expect(third[0]?.id).not.toBe(first[0]?.id);
|
||||
});
|
||||
|
||||
it("does not leak parent Paperclip instance env into runtime service commands", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-env-"));
|
||||
const workspace = buildWorkspace(workspaceRoot);
|
||||
const envCapturePath = path.join(workspaceRoot, "captured-env.json");
|
||||
const serviceCommand = [
|
||||
"node -e",
|
||||
JSON.stringify(
|
||||
[
|
||||
"const fs = require('node:fs');",
|
||||
`fs.writeFileSync(${JSON.stringify(envCapturePath)}, JSON.stringify({`,
|
||||
"paperclipConfig: process.env.PAPERCLIP_CONFIG ?? null,",
|
||||
"paperclipHome: process.env.PAPERCLIP_HOME ?? null,",
|
||||
"paperclipInstanceId: process.env.PAPERCLIP_INSTANCE_ID ?? null,",
|
||||
"databaseUrl: process.env.DATABASE_URL ?? null,",
|
||||
"customEnv: process.env.RUNTIME_CUSTOM_ENV ?? null,",
|
||||
"port: process.env.PORT ?? null,",
|
||||
"}));",
|
||||
"require('node:http').createServer((req, res) => res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1');",
|
||||
].join(" "),
|
||||
),
|
||||
].join(" ");
|
||||
|
||||
process.env.PAPERCLIP_CONFIG = "/tmp/base-paperclip-config.json";
|
||||
process.env.PAPERCLIP_HOME = "/tmp/base-paperclip-home";
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "base-instance";
|
||||
process.env.DATABASE_URL = "postgres://shared-db.example.com/paperclip";
|
||||
|
||||
const runId = "run-env";
|
||||
leasedRunIds.add(runId);
|
||||
|
||||
const services = await ensureRuntimeServicesForRun({
|
||||
runId,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId: "execution-workspace-1",
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command: serviceCommand,
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: {
|
||||
type: "on_run_finish",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {
|
||||
RUNTIME_CUSTOM_ENV: "from-adapter",
|
||||
},
|
||||
});
|
||||
|
||||
expect(services).toHaveLength(1);
|
||||
const captured = JSON.parse(await fs.readFile(envCapturePath, "utf8")) as Record<string, string | null>;
|
||||
expect(captured.paperclipConfig).toBeNull();
|
||||
expect(captured.paperclipHome).toBeNull();
|
||||
expect(captured.paperclipInstanceId).toBeNull();
|
||||
expect(captured.databaseUrl).toBeNull();
|
||||
expect(captured.customEnv).toBe("from-adapter");
|
||||
expect(captured.port).toMatch(/^\d+$/);
|
||||
expect(services[0]?.executionWorkspaceId).toBe("execution-workspace-1");
|
||||
expect(services[0]?.scopeType).toBe("execution_workspace");
|
||||
expect(services[0]?.scopeId).toBe("execution-workspace-1");
|
||||
});
|
||||
|
||||
it("stops execution workspace runtime services by executionWorkspaceId", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-"));
|
||||
const workspace = buildWorkspace(workspaceRoot);
|
||||
const runId = "run-stop";
|
||||
leasedRunIds.add(runId);
|
||||
|
||||
const services = await ensureRuntimeServicesForRun({
|
||||
runId,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId: "execution-workspace-stop",
|
||||
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,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(services[0]?.url).toBeTruthy();
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
executionWorkspaceId: "execution-workspace-stop",
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
await releaseRuntimeServicesForRun(runId);
|
||||
leasedRunIds.delete(runId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
await expect(fetch(services[0]!.url!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not stop services in sibling directories when matching by workspace cwd", async () => {
|
||||
const workspaceParent = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-sibling-"));
|
||||
const targetWorkspaceRoot = path.join(workspaceParent, "project");
|
||||
const siblingWorkspaceRoot = path.join(workspaceParent, "project-extended", "service");
|
||||
await fs.mkdir(targetWorkspaceRoot, { recursive: true });
|
||||
await fs.mkdir(siblingWorkspaceRoot, { recursive: true });
|
||||
|
||||
const siblingWorkspace = buildWorkspace(siblingWorkspaceRoot);
|
||||
const runId = "run-sibling";
|
||||
leasedRunIds.add(runId);
|
||||
|
||||
const services = await ensureRuntimeServicesForRun({
|
||||
runId,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace: siblingWorkspace,
|
||||
executionWorkspaceId: "execution-workspace-sibling",
|
||||
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,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
executionWorkspaceId: "execution-workspace-target",
|
||||
workspaceCwd: targetWorkspaceRoot,
|
||||
});
|
||||
|
||||
const response = await fetch(services[0]!.url!);
|
||||
expect(await response.text()).toBe("ok");
|
||||
|
||||
await releaseRuntimeServicesForRun(runId);
|
||||
leasedRunIds.delete(runId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAdapterManagedRuntimeServices", () => {
|
||||
it("fills workspace defaults and derives stable ids for adapter-managed services", () => {
|
||||
const workspace = buildWorkspace("/tmp/project");
|
||||
const now = new Date("2026-03-09T12:00:00.000Z");
|
||||
|
||||
const first = normalizeAdapterManagedRuntimeServices({
|
||||
adapterType: "openclaw_gateway",
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Gateway Agent",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Worktree support",
|
||||
},
|
||||
workspace,
|
||||
reports: [
|
||||
{
|
||||
serviceName: "preview",
|
||||
url: "https://preview.example/run-1",
|
||||
providerRef: "sandbox-123",
|
||||
scopeType: "run",
|
||||
},
|
||||
],
|
||||
now,
|
||||
});
|
||||
|
||||
const second = normalizeAdapterManagedRuntimeServices({
|
||||
adapterType: "openclaw_gateway",
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Gateway Agent",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Worktree support",
|
||||
},
|
||||
workspace,
|
||||
reports: [
|
||||
{
|
||||
serviceName: "preview",
|
||||
url: "https://preview.example/run-1",
|
||||
providerRef: "sandbox-123",
|
||||
scopeType: "run",
|
||||
},
|
||||
],
|
||||
now,
|
||||
});
|
||||
|
||||
expect(first).toHaveLength(1);
|
||||
expect(first[0]).toMatchObject({
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
executionWorkspaceId: null,
|
||||
issueId: "issue-1",
|
||||
serviceName: "preview",
|
||||
provider: "adapter_managed",
|
||||
status: "running",
|
||||
healthStatus: "healthy",
|
||||
startedByRunId: "run-1",
|
||||
});
|
||||
expect(first[0]?.id).toBe(second[0]?.id);
|
||||
});
|
||||
|
||||
it("prefers execution workspace ids over cwd for execution-scoped adapter services", () => {
|
||||
const workspace = buildWorkspace("/tmp/project");
|
||||
|
||||
const refs = normalizeAdapterManagedRuntimeServices({
|
||||
adapterType: "openclaw_gateway",
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Gateway Agent",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId: "execution-workspace-1",
|
||||
reports: [
|
||||
{
|
||||
serviceName: "preview",
|
||||
scopeType: "execution_workspace",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(refs[0]).toMatchObject({
|
||||
scopeType: "execution_workspace",
|
||||
scopeId: "execution-workspace-1",
|
||||
executionWorkspaceId: "execution-workspace-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { AdapterModel } from "./types.js";
|
||||
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
|
||||
import { readConfigFile } from "../config-file.js";
|
||||
|
||||
const OPENAI_MODELS_ENDPOINT = "https://api.openai.com/v1/models";
|
||||
const OPENAI_MODELS_TIMEOUT_MS = 5000;
|
||||
const OPENAI_MODELS_CACHE_TTL_MS = 60_000;
|
||||
|
||||
let cached: { keyFingerprint: string; expiresAt: number; models: AdapterModel[] } | null = null;
|
||||
|
||||
function fingerprint(apiKey: string): string {
|
||||
return `${apiKey.length}:${apiKey.slice(-6)}`;
|
||||
}
|
||||
|
||||
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AdapterModel[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push({ id, label: model.label.trim() || id });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function mergedWithFallback(models: AdapterModel[]): AdapterModel[] {
|
||||
return dedupeModels([
|
||||
...models,
|
||||
...codexFallbackModels,
|
||||
]).sort((a, b) => a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }));
|
||||
}
|
||||
|
||||
function resolveOpenAiApiKey(): string | null {
|
||||
const envKey = process.env.OPENAI_API_KEY?.trim();
|
||||
if (envKey) return envKey;
|
||||
|
||||
const config = readConfigFile();
|
||||
if (config?.llm?.provider !== "openai") return null;
|
||||
const configKey = config.llm.apiKey?.trim();
|
||||
return configKey && configKey.length > 0 ? configKey : null;
|
||||
}
|
||||
|
||||
async function fetchOpenAiModels(apiKey: string): Promise<AdapterModel[]> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OPENAI_MODELS_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(OPENAI_MODELS_ENDPOINT, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
|
||||
const payload = (await response.json()) as { data?: unknown };
|
||||
const data = Array.isArray(payload.data) ? payload.data : [];
|
||||
const models: AdapterModel[] = [];
|
||||
for (const item of data) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
const id = (item as { id?: unknown }).id;
|
||||
if (typeof id !== "string" || id.trim().length === 0) continue;
|
||||
models.push({ id, label: id });
|
||||
}
|
||||
return dedupeModels(models);
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCodexModels(): Promise<AdapterModel[]> {
|
||||
const apiKey = resolveOpenAiApiKey();
|
||||
const fallback = dedupeModels(codexFallbackModels);
|
||||
if (!apiKey) return fallback;
|
||||
|
||||
const now = Date.now();
|
||||
const keyFingerprint = fingerprint(apiKey);
|
||||
if (cached && cached.keyFingerprint === keyFingerprint && cached.expiresAt > now) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
const fetched = await fetchOpenAiModels(apiKey);
|
||||
if (fetched.length > 0) {
|
||||
const merged = mergedWithFallback(fetched);
|
||||
cached = {
|
||||
keyFingerprint,
|
||||
expiresAt: now + OPENAI_MODELS_CACHE_TTL_MS,
|
||||
models: merged,
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
if (cached && cached.keyFingerprint === keyFingerprint && cached.models.length > 0) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function resetCodexModelsCacheForTests() {
|
||||
cached = null;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
|
||||
import type { AdapterModel } from "./types.js";
|
||||
|
||||
const CURSOR_MODELS_TIMEOUT_MS = 5_000;
|
||||
const CURSOR_MODELS_CACHE_TTL_MS = 60_000;
|
||||
const MAX_BUFFER_BYTES = 512 * 1024;
|
||||
|
||||
let cached: { expiresAt: number; models: AdapterModel[] } | null = null;
|
||||
|
||||
type CursorModelsCommandResult = {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
hasError: boolean;
|
||||
};
|
||||
|
||||
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AdapterModel[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push({ id, label: model.label.trim() || id });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function sanitizeModelId(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^["'`]+|["'`]+$/g, "")
|
||||
.replace(/\(.*\)\s*$/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isLikelyModelId(raw: string): boolean {
|
||||
const value = sanitizeModelId(raw);
|
||||
if (!value) return false;
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value);
|
||||
}
|
||||
|
||||
function pushModelId(target: AdapterModel[], raw: string) {
|
||||
const id = sanitizeModelId(raw);
|
||||
if (!isLikelyModelId(id)) return;
|
||||
target.push({ id, label: id });
|
||||
}
|
||||
|
||||
function collectFromJsonValue(value: unknown, target: AdapterModel[]) {
|
||||
if (typeof value === "string") {
|
||||
pushModelId(target, value);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(value)) return;
|
||||
|
||||
for (const item of value) {
|
||||
if (typeof item === "string") {
|
||||
pushModelId(target, item);
|
||||
continue;
|
||||
}
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
const id = (item as { id?: unknown }).id;
|
||||
if (typeof id === "string") {
|
||||
pushModelId(target, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCursorModelsOutput(stdout: string, stderr: string): AdapterModel[] {
|
||||
const models: AdapterModel[] = [];
|
||||
const combined = `${stdout}\n${stderr}`;
|
||||
|
||||
const trimmedStdout = stdout.trim();
|
||||
if (trimmedStdout.startsWith("{") || trimmedStdout.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedStdout) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
collectFromJsonValue(parsed, models);
|
||||
} else if (typeof parsed === "object" && parsed !== null) {
|
||||
const rec = parsed as Record<string, unknown>;
|
||||
collectFromJsonValue(rec.models, models);
|
||||
collectFromJsonValue(rec.data, models);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed JSON and continue parsing plain text formats.
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of combined.matchAll(/available models?:\s*([^\n]+)/gi)) {
|
||||
const list = match[1] ?? "";
|
||||
for (const token of list.split(",")) {
|
||||
pushModelId(models, token);
|
||||
}
|
||||
}
|
||||
|
||||
for (const lineRaw of combined.split(/\r?\n/)) {
|
||||
const line = lineRaw.trim();
|
||||
if (!line) continue;
|
||||
const bullet = line.replace(/^[-*]\s+/, "").trim();
|
||||
if (!bullet || bullet.includes(" ")) continue;
|
||||
pushModelId(models, bullet);
|
||||
}
|
||||
|
||||
return dedupeModels(models);
|
||||
}
|
||||
|
||||
function mergedWithFallback(models: AdapterModel[]): AdapterModel[] {
|
||||
return dedupeModels([...models, ...cursorFallbackModels]);
|
||||
}
|
||||
|
||||
function defaultCursorModelsRunner(): CursorModelsCommandResult {
|
||||
const result = spawnSync("agent", ["models"], {
|
||||
encoding: "utf8",
|
||||
timeout: CURSOR_MODELS_TIMEOUT_MS,
|
||||
maxBuffer: MAX_BUFFER_BYTES,
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
||||
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
||||
hasError: Boolean(result.error),
|
||||
};
|
||||
}
|
||||
|
||||
let cursorModelsRunner: () => CursorModelsCommandResult = defaultCursorModelsRunner;
|
||||
|
||||
function fetchCursorModelsFromCli(): AdapterModel[] {
|
||||
const result = cursorModelsRunner();
|
||||
const { stdout, stderr } = result;
|
||||
if (result.hasError && stdout.trim().length === 0 && stderr.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
if ((result.status ?? 1) !== 0 && !/available models?:/i.test(`${stdout}\n${stderr}`)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parseCursorModelsOutput(stdout, stderr);
|
||||
}
|
||||
|
||||
export async function listCursorModels(): Promise<AdapterModel[]> {
|
||||
const now = Date.now();
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
const discovered = fetchCursorModelsFromCli();
|
||||
if (discovered.length > 0) {
|
||||
const merged = mergedWithFallback(discovered);
|
||||
cached = {
|
||||
expiresAt: now + CURSOR_MODELS_CACHE_TTL_MS,
|
||||
models: merged,
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
if (cached && cached.models.length > 0) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
return dedupeModels(cursorFallbackModels);
|
||||
}
|
||||
|
||||
export function resetCursorModelsCacheForTests() {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
export function setCursorModelsRunnerForTests(runner: (() => CursorModelsCommandResult) | null) {
|
||||
cursorModelsRunner = runner ?? defaultCursorModelsRunner;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "../types.js";
|
||||
import { asString, asNumber, parseObject } from "../utils.js";
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { config, runId, agent, context } = ctx;
|
||||
const url = asString(config.url, "");
|
||||
if (!url) throw new Error("HTTP adapter missing url");
|
||||
|
||||
const method = asString(config.method, "POST");
|
||||
const timeoutMs = asNumber(config.timeoutMs, 0);
|
||||
const headers = parseObject(config.headers) as Record<string, string>;
|
||||
const payloadTemplate = parseObject(config.payloadTemplate);
|
||||
const body = { ...payloadTemplate, agentId: agent.id, runId, context };
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
...(timer ? { signal: controller.signal } : {}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP invoke failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
summary: `HTTP ${method} ${url}`,
|
||||
};
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ServerAdapterModule } from "../types.js";
|
||||
import { execute } from "./execute.js";
|
||||
import { testEnvironment } from "./test.js";
|
||||
|
||||
export const httpAdapter: ServerAdapterModule = {
|
||||
type: "http",
|
||||
execute,
|
||||
testEnvironment,
|
||||
models: [],
|
||||
agentConfigurationDoc: `# http agent configuration
|
||||
|
||||
Adapter: http
|
||||
|
||||
Core fields:
|
||||
- url (string, required): endpoint to invoke
|
||||
- method (string, optional): HTTP method, default POST
|
||||
- headers (object, optional): request headers
|
||||
- payloadTemplate (object, optional): JSON payload template
|
||||
- timeoutSec (number, optional): request timeout in seconds
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "../types.js";
|
||||
import { asString, parseObject } from "../utils.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function normalizeMethod(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
return trimmed.length > 0 ? trimmed.toUpperCase() : "POST";
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const urlValue = asString(config.url, "");
|
||||
const method = normalizeMethod(asString(config.method, "POST"));
|
||||
|
||||
if (!urlValue) {
|
||||
checks.push({
|
||||
code: "http_url_missing",
|
||||
level: "error",
|
||||
message: "HTTP adapter requires a URL.",
|
||||
hint: "Set adapterConfig.url to an absolute http(s) endpoint.",
|
||||
});
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
let url: URL | null = null;
|
||||
try {
|
||||
url = new URL(urlValue);
|
||||
} catch {
|
||||
checks.push({
|
||||
code: "http_url_invalid",
|
||||
level: "error",
|
||||
message: `Invalid URL: ${urlValue}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (url && url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
checks.push({
|
||||
code: "http_url_protocol_invalid",
|
||||
level: "error",
|
||||
message: `Unsupported URL protocol: ${url.protocol}`,
|
||||
hint: "Use an http:// or https:// endpoint.",
|
||||
});
|
||||
}
|
||||
|
||||
if (url) {
|
||||
checks.push({
|
||||
code: "http_url_valid",
|
||||
level: "info",
|
||||
message: `Configured endpoint: ${url.toString()}`,
|
||||
});
|
||||
}
|
||||
|
||||
checks.push({
|
||||
code: "http_method_configured",
|
||||
level: "info",
|
||||
message: `Configured method: ${method}`,
|
||||
});
|
||||
|
||||
if (url && (url.protocol === "http:" || url.protocol === "https:")) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok && response.status !== 405 && response.status !== 501) {
|
||||
checks.push({
|
||||
code: "http_endpoint_probe_unexpected_status",
|
||||
level: "warn",
|
||||
message: `Endpoint probe returned HTTP ${response.status}.`,
|
||||
hint: "Verify the endpoint is reachable from the Paperclip server host.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "http_endpoint_probe_ok",
|
||||
level: "info",
|
||||
message: "Endpoint responded to a HEAD probe.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "http_endpoint_probe_failed",
|
||||
level: "warn",
|
||||
message: err instanceof Error ? err.message : "Endpoint probe failed",
|
||||
hint: "This may be expected in restricted networks; verify connectivity when invoking runs.",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter } from "./registry.js";
|
||||
export type {
|
||||
ServerAdapterModule,
|
||||
AdapterExecutionContext,
|
||||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterEnvironmentCheckLevel,
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestStatus,
|
||||
AdapterEnvironmentTestResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterSessionCodec,
|
||||
UsageSummary,
|
||||
AdapterAgent,
|
||||
AdapterRuntime,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
export { runningProcesses } from "./utils.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "../types.js";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
redactEnvForLogs,
|
||||
runChildProcess,
|
||||
} from "../utils.js";
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, config, onLog, onMeta } = ctx;
|
||||
const command = asString(config.command, "");
|
||||
if (!command) throw new Error("Process adapter missing command");
|
||||
|
||||
const args = asStringArray(config.args);
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 15);
|
||||
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "process",
|
||||
command,
|
||||
cwd,
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
|
||||
if (proc.timedOut) {
|
||||
return {
|
||||
exitCode: proc.exitCode,
|
||||
signal: proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
};
|
||||
}
|
||||
|
||||
if ((proc.exitCode ?? 0) !== 0) {
|
||||
return {
|
||||
exitCode: proc.exitCode,
|
||||
signal: proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage: `Process exited with code ${proc.exitCode ?? -1}`,
|
||||
resultJson: {
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: proc.exitCode,
|
||||
signal: proc.signal,
|
||||
timedOut: false,
|
||||
resultJson: {
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ServerAdapterModule } from "../types.js";
|
||||
import { execute } from "./execute.js";
|
||||
import { testEnvironment } from "./test.js";
|
||||
|
||||
export const processAdapter: ServerAdapterModule = {
|
||||
type: "process",
|
||||
execute,
|
||||
testEnvironment,
|
||||
models: [],
|
||||
agentConfigurationDoc: `# process agent configuration
|
||||
|
||||
Adapter: process
|
||||
|
||||
Core fields:
|
||||
- command (string, required): command to execute
|
||||
- args (string[] | string, optional): command arguments
|
||||
- cwd (string, optional): absolute working directory
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
`,
|
||||
};
|
||||
Loaded 100 of 1081 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user