Merge public-gh/master into review/pr-162

This commit is contained in:
Dotta
2026-03-16 08:47:05 -05:00
536 changed files with 103660 additions and 9971 deletions

View File

@@ -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" }]);
});
});

View File

@@ -5,6 +5,10 @@ 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,
@@ -82,6 +86,24 @@ describe("adapter session codecs", () => {
});
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", () => {
@@ -146,3 +168,26 @@ describe("cursor resume recovery detection", () => {
).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);
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -4,6 +4,8 @@ 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", () => {
it("creates a missing working directory when cwd is absolute", async () => {
const cwd = path.join(
@@ -29,4 +31,45 @@ describe("codex_local environment diagnostics", () => {
expect(stats.isDirectory()).toBe(true);
await fs.rm(path.dirname(cwd), { 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 });
}
});
});

View File

@@ -70,6 +70,7 @@ describe("codex_local ui stdout parser", () => {
kind: "tool_call",
ts,
name: "command_execution",
toolUseId: "item_2",
input: { id: "item_2", command: "/bin/zsh -lc ls" },
},
]);
@@ -106,7 +107,7 @@ describe("codex_local ui stdout parser", () => {
item: {
id: "item_52",
type: "file_change",
changes: [{ path: "/home/user/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
changes: [{ path: "/Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
status: "completed",
},
}),
@@ -116,7 +117,7 @@ describe("codex_local ui stdout parser", () => {
{
kind: "system",
ts,
text: "file changes: update /home/user/project/ui/src/pages/AgentDetail.tsx",
text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx",
},
]);
});

View File

@@ -0,0 +1,208 @@
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[];
};
describe("codex execute", () => {
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", "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;
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-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 () => {},
});
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");
const isolatedSkill = path.join(isolatedCodexHome, "skills", "paperclip");
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(isolatedSkill)).isSymbolicLink()).toBe(true);
} 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);
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 });
}
});
});

View File

@@ -0,0 +1,91 @@
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 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: string[] = [];
await ensureCodexSkillsInjected(
async (_stream, chunk) => {
logs.push(chunk);
},
{
skillsHome,
skillsEntries: [{ name: "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.some((line) => line.includes('Repaired Codex skill "paperclip"'))).toBe(true);
});
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: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],
});
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
await fs.realpath(path.join(customRoot, "custom", "paperclip")),
);
});
});

View File

@@ -165,6 +165,7 @@ describe("cursor ui stdout parser", () => {
kind: "tool_call",
ts,
name: "shellToolCall",
toolUseId: "call_shell_1",
input: { command: longCommand },
},
]);
@@ -254,7 +255,7 @@ describe("cursor ui stdout parser", () => {
}),
ts,
),
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", input: { path: "README.md" } }]);
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", toolUseId: "call_1", input: { path: "README.md" } }]);
expect(
parseCursorStdoutLine(

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import {
buildExecutionWorkspaceAdapterConfig,
defaultIssueExecutionWorkspaceSettingsForProject,
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",
}),
).toEqual({ mode: "isolated" });
expect(
defaultIssueExecutionWorkspaceSettingsForProject({
enabled: true,
defaultMode: "project_primary",
}),
).toEqual({ mode: "project_primary" });
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
});
it("prefers explicit issue mode over project policy and legacy overrides", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "project_primary" },
issueSettings: { mode: "isolated" },
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
});
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "isolated" },
issueSettings: null,
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
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",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/main",
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
issueSettings: null,
mode: "isolated",
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" },
issueSettings: { mode: "project_primary" },
mode: "project_primary",
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",
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: "project_primary",
});
});
});

View File

@@ -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.");
});
});

View File

@@ -0,0 +1,91 @@
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;
}
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");
await fs.rm(root, { recursive: true, force: true });
});
});

View File

@@ -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");
});
});

View File

@@ -0,0 +1,168 @@
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 as final argument 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("--approval-mode");
expect(capture.argv).toContain("yolo");
expect(capture.argv.at(-1)).toContain("Follow the paperclip heartbeat.");
expect(capture.argv.at(-1)).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 });
}
});
});

View File

@@ -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();
});
});

View File

@@ -93,16 +93,26 @@ describe("shouldResetTaskSessionForWake", () => {
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
});
it("resets session context on timer heartbeats", () => {
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(true);
it("preserves session context on timer heartbeats", () => {
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
});
it("resets session context on manual on-demand invokes", () => {
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);
});

View File

@@ -40,7 +40,7 @@ afterEach(() => {
describe("notifyHireApproved", () => {
it("writes success activity when adapter hook returns ok", async () => {
vi.mocked(findServerAdapter).mockReturnValue({
type: "openclaw",
type: "openclaw_gateway",
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
} as any);
@@ -48,7 +48,7 @@ describe("notifyHireApproved", () => {
id: "a1",
companyId: "c1",
name: "OpenClaw Agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
});
await expect(
@@ -65,7 +65,7 @@ describe("notifyHireApproved", () => {
expect.objectContaining({
action: "hire_hook.succeeded",
entityId: "a1",
details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw" }),
details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw_gateway" }),
}),
);
});
@@ -116,7 +116,7 @@ describe("notifyHireApproved", () => {
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
vi.mocked(findServerAdapter).mockReturnValue({
type: "openclaw",
type: "openclaw_gateway",
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
} as any);
@@ -124,7 +124,7 @@ describe("notifyHireApproved", () => {
id: "a1",
companyId: "c1",
name: "OpenClaw Agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
});
await expect(
@@ -148,7 +148,7 @@ describe("notifyHireApproved", () => {
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
vi.mocked(findServerAdapter).mockReturnValue({
type: "openclaw",
type: "openclaw_gateway",
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
} as any);
@@ -156,7 +156,7 @@ describe("notifyHireApproved", () => {
id: "a1",
companyId: "c1",
name: "OpenClaw Agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
});
await expect(

View File

@@ -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();
});
});

View File

@@ -1,211 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildJoinDefaultsPayloadForAccept } from "../routes/access.js";
describe("buildJoinDefaultsPayloadForAccept", () => {
it("maps OpenClaw compatibility fields into agent defaults", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: null,
responsesWebhookUrl: "http://localhost:18789/v1/responses",
paperclipApiUrl: "http://host.docker.internal:3100",
inboundOpenClawAuthHeader: "gateway-token",
}) as Record<string, unknown>;
expect(result).toMatchObject({
url: "http://localhost:18789/v1/responses",
paperclipApiUrl: "http://host.docker.internal:3100",
webhookAuthHeader: "Bearer gateway-token",
headers: {
"x-openclaw-auth": "gateway-token",
},
});
});
it("does not overwrite explicit OpenClaw endpoint defaults when already provided", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
url: "https://example.com/v1/responses",
method: "POST",
headers: {
"x-openclaw-auth": "existing-token",
},
paperclipApiUrl: "https://paperclip.example.com",
},
responsesWebhookUrl: "https://legacy.example.com/v1/responses",
responsesWebhookMethod: "PUT",
paperclipApiUrl: "https://legacy-paperclip.example.com",
inboundOpenClawAuthHeader: "legacy-token",
}) as Record<string, unknown>;
expect(result).toMatchObject({
url: "https://example.com/v1/responses",
method: "POST",
paperclipApiUrl: "https://paperclip.example.com",
webhookAuthHeader: "Bearer existing-token",
headers: {
"x-openclaw-auth": "existing-token",
},
});
});
it("preserves explicit webhookAuthHeader when configured", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
url: "https://example.com/v1/responses",
webhookAuthHeader: "Bearer explicit-token",
headers: {
"x-openclaw-auth": "existing-token",
},
},
inboundOpenClawAuthHeader: "legacy-token",
}) as Record<string, unknown>;
expect(result).toMatchObject({
webhookAuthHeader: "Bearer explicit-token",
headers: {
"x-openclaw-auth": "existing-token",
},
});
});
it("accepts auth from agentDefaultsPayload.headers.x-openclaw-auth", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
url: "http://127.0.0.1:18789/v1/responses",
method: "POST",
headers: {
"x-openclaw-auth": "gateway-token",
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-auth": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts auth from agentDefaultsPayload.headers.x-openclaw-token", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
url: "http://127.0.0.1:18789/hooks/agent",
method: "POST",
headers: {
"x-openclaw-token": "gateway-token",
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-token": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts inbound x-openclaw-token compatibility header", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: null,
inboundOpenClawTokenHeader: "gateway-token",
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-token": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts wrapped auth values in headers for compatibility", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
headers: {
"x-openclaw-auth": {
value: "gateway-token",
},
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-auth": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts auth headers provided as tuple entries", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
headers: [["x-openclaw-auth", "gateway-token"]],
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-auth": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts auth headers provided as name/value entries", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
headers: [{ name: "x-openclaw-auth", value: { authToken: "gateway-token" } }],
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-auth": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("accepts auth headers wrapped in a single unknown key", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
defaultsPayload: {
headers: {
"x-openclaw-auth": {
gatewayToken: "gateway-token",
},
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-auth": "gateway-token",
},
webhookAuthHeader: "Bearer gateway-token",
});
});
it("leaves non-openclaw payloads unchanged", () => {
const defaultsPayload = { command: "echo hello" };
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "process",
defaultsPayload,
responsesWebhookUrl: "https://ignored.example.com",
inboundOpenClawAuthHeader: "ignored-token",
});
expect(result).toEqual(defaultsPayload);
});
});

View File

@@ -1,63 +1,55 @@
import { describe, expect, it } from "vitest";
import {
buildJoinDefaultsPayloadForAccept,
canReplayOpenClawInviteAccept,
canReplayOpenClawGatewayInviteAccept,
mergeJoinDefaultsPayloadForReplay,
} from "../routes/access.js";
describe("canReplayOpenClawInviteAccept", () => {
it("allows replay only for openclaw agent joins in pending or approved state", () => {
describe("canReplayOpenClawGatewayInviteAccept", () => {
it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => {
expect(
canReplayOpenClawInviteAccept({
canReplayOpenClawGatewayInviteAccept({
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
status: "pending_approval",
},
}),
).toBe(true);
expect(
canReplayOpenClawInviteAccept({
canReplayOpenClawGatewayInviteAccept({
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
status: "approved",
},
}),
).toBe(true);
expect(
canReplayOpenClawInviteAccept({
canReplayOpenClawGatewayInviteAccept({
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
status: "rejected",
},
}),
).toBe(false);
expect(
canReplayOpenClawInviteAccept({
canReplayOpenClawGatewayInviteAccept({
requestType: "human",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw",
status: "pending_approval",
},
}),
).toBe(false);
expect(
canReplayOpenClawInviteAccept({
requestType: "agent",
adapterType: "process",
existingJoinRequest: {
requestType: "agent",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
status: "pending_approval",
},
}),
@@ -66,36 +58,34 @@ describe("canReplayOpenClawInviteAccept", () => {
});
describe("mergeJoinDefaultsPayloadForReplay", () => {
it("merges replay payloads and preserves existing fields while allowing auth/header overrides", () => {
it("merges replay payloads and allows gateway token override", () => {
const merged = mergeJoinDefaultsPayloadForReplay(
{
url: "https://old.example/v1/responses",
method: "POST",
url: "ws://old.example:18789",
paperclipApiUrl: "http://host.docker.internal:3100",
headers: {
"x-openclaw-auth": "old-token",
"x-openclaw-token": "old-token-1234567890",
"x-custom": "keep-me",
},
},
{
paperclipApiUrl: "https://paperclip.example.com",
headers: {
"x-openclaw-auth": "new-token",
"x-openclaw-token": "new-token-1234567890",
},
},
);
const normalized = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw",
adapterType: "openclaw_gateway",
defaultsPayload: merged,
inboundOpenClawAuthHeader: null,
}) as Record<string, unknown>;
expect(normalized.url).toBe("https://old.example/v1/responses");
expect(normalized.url).toBe("ws://old.example:18789");
expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com");
expect(normalized.webhookAuthHeader).toBe("Bearer new-token");
expect(normalized.headers).toMatchObject({
"x-openclaw-auth": "new-token",
"x-openclaw-token": "new-token-1234567890",
"x-custom": "keep-me",
});
});

View File

@@ -37,21 +37,22 @@ describe("buildInviteOnboardingTextDocument", () => {
allowedHostnames: [],
});
expect(text).toContain("Paperclip OpenClaw Onboarding");
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("/api/invites/token-123/test-resolution");
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("You MUST include agentDefaultsPayload.headers.x-openclaw-auth");
expect(text).toContain("will fail with 401 Unauthorized");
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", () => {

View File

@@ -0,0 +1,59 @@
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",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("does not force a company goal when the issue belongs to a project", () => {
expect(
resolveIssueGoalId({
projectId: "project-1",
goalId: null,
defaultGoalId: "goal-1",
}),
).toBeNull();
});
it("backfills the company goal on update for legacy no-project issues", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: null,
defaultGoalId: "goal-1",
}),
).toBe("goal-1");
});
it("clears the fallback when a project is added later", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: "goal-1",
projectId: "project-1",
goalId: null,
defaultGoalId: "goal-1",
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
CURRENT_USER_REDACTION_TOKEN,
redactCurrentUserText,
redactCurrentUserValue,
} from "../log-redaction.js";
describe("log redaction", () => {
it("redacts the active username inside home-directory paths", () => {
const userName = "paperclipuser";
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/${CURRENT_USER_REDACTION_TOKEN}/paperclip`);
expect(result).toContain(`home=/home/${CURRENT_USER_REDACTION_TOKEN}/workspace`);
expect(result).toContain(`win=C:\\Users\\${CURRENT_USER_REDACTION_TOKEN}\\paperclip`);
expect(result).not.toContain(userName);
});
it("redacts standalone username mentions without mangling larger tokens", () => {
const userName = "paperclipuser";
const result = redactCurrentUserText(
`user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`,
{
userNames: [userName],
homeDirs: [],
},
);
expect(result).toBe(
`user ${CURRENT_USER_REDACTION_TOKEN} said ${CURRENT_USER_REDACTION_TOKEN}/project should stay but apaperclipuserz should not change`,
);
});
it("recursively redacts nested event payloads", () => {
const userName = "paperclipuser";
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/${CURRENT_USER_REDACTION_TOKEN}/paperclip`,
prompt: `open /Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip/ui`,
nested: {
author: CURRENT_USER_REDACTION_TOKEN,
},
values: [CURRENT_USER_REDACTION_TOKEN, `/home/${CURRENT_USER_REDACTION_TOKEN}/project`],
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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);
});
});

View File

@@ -0,0 +1,181 @@
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 mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
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");
});
});

View File

@@ -103,6 +103,7 @@ describe("opencode_local ui stdout parser", () => {
kind: "tool_call",
ts,
name: "bash",
toolUseId: "call_1",
input: { command: "ls -1" },
},
{

View File

@@ -0,0 +1,61 @@
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.name)).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);
});
});

View File

@@ -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" },
]);
});
});

View 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);
});
});

View File

@@ -52,5 +52,5 @@ describe("privateHostnameGuard", () => {
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);
});

View File

@@ -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("!!!");
});
});

View File

@@ -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"');
});
});

View File

@@ -0,0 +1,386 @@
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 {
ensureRuntimeServicesForRun,
normalizeAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
type RealizedExecutionWorkspace,
} from "../services/workspace-runtime.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,
};
}
afterEach(async () => {
await Promise.all(
Array.from(leasedRunIds).map(async (runId) => {
await releaseRuntimeServicesForRun(runId);
leasedRunIds.delete(runId);
}),
);
});
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");
});
});
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);
});
});
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",
issueId: "issue-1",
serviceName: "preview",
provider: "adapter_managed",
status: "running",
healthStatus: "healthy",
startedByRunId: "run-1",
});
expect(first[0]?.id).toBe(second[0]?.id);
});
});